diff --git a/.claude/skills/frigg/SKILL.md b/.claude/skills/frigg/SKILL.md index dc222e188..d5d7f1a45 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/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/docs/architecture-decisions/README.md b/docs/architecture-decisions/README.md index 0b27cf895..7504a2b94 100644 --- a/docs/architecture-decisions/README.md +++ b/docs/architecture-decisions/README.md @@ -22,10 +22,6 @@ An ADR documents a significant architectural decision made in the project, inclu | [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 | -| [006](./006-integration-router-v2.md) | Integration Router v2 Restructuring | Accepted | 2025-12-14 | -| [007](./007-management-ui-architecture.md) | Management UI Architecture | Accepted | 2025-12-14 | -| [008](./008-frigg-cli-start-command.md) | Frigg CLI Start Command | Accepted | 2025-12-14 | -| [009](./009-e2e-test-package.md) | E2E Test Package Architecture | Accepted | 2025-12-15 | ## ADR Template diff --git a/package-lock.json b/package-lock.json index 8007e74bf..0b691a6c4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36,27 +36,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@asamuzakjp/css-color": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", - "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@csstools/css-calc": "^2.1.3", - "@csstools/css-color-parser": "^3.0.9", - "@csstools/css-parser-algorithms": "^3.0.4", - "@csstools/css-tokenizer": "^3.0.3", - "lru-cache": "^10.4.3" - } - }, - "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true, - "license": "ISC" - }, "node_modules/@atomist/slack-messages": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/@atomist/slack-messages/-/slack-messages-1.2.2.tgz", @@ -10419,121 +10398,6 @@ "node": ">=12" } }, - "node_modules/@csstools/color-helpers": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", - "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0", - "engines": { - "node": ">=18" - } - }, - "node_modules/@csstools/css-calc": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", - "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4" - } - }, - "node_modules/@csstools/css-color-parser": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", - "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "dependencies": { - "@csstools/color-helpers": "^5.1.0", - "@csstools/css-calc": "^2.1.4" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4" - } - }, - "node_modules/@csstools/css-parser-algorithms": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", - "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@csstools/css-tokenizer": "^3.0.4" - } - }, - "node_modules/@csstools/css-tokenizer": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", - "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "engines": { - "node": ">=18" - } - }, "node_modules/@date-io/core": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/@date-io/core/-/core-3.2.0.tgz", @@ -11468,10 +11332,6 @@ "resolved": "packages/admin-scripts", "link": true }, - "node_modules/@friggframework/ai-agents": { - "resolved": "packages/ai-agents", - "link": true - }, "node_modules/@friggframework/core": { "resolved": "packages/core", "link": true @@ -11480,10 +11340,6 @@ "resolved": "packages/devtools", "link": true }, - "node_modules/@friggframework/e2e": { - "resolved": "packages/e2e", - "link": true - }, "node_modules/@friggframework/eslint-config": { "resolved": "packages/eslint-config", "link": true @@ -19524,56 +19380,6 @@ "node": ">= 14.16" } }, - "node_modules/@vitest/mocker": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", - "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/spy": "2.1.9", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.12" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "msw": "^2.4.9", - "vite": "^5.0.0" - }, - "peerDependenciesMeta": { - "msw": { - "optional": true - }, - "vite": { - "optional": true - } - } - }, - "node_modules/@vitest/mocker/node_modules/@vitest/spy": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", - "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyspy": "^3.0.2" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/mocker/node_modules/tinyspy": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", - "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, "node_modules/@vitest/pretty-format": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", @@ -19586,110 +19392,6 @@ "url": "https://opencollective.com/vitest" } }, - "node_modules/@vitest/runner": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", - "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/utils": "2.1.9", - "pathe": "^1.1.2" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/runner/node_modules/@vitest/pretty-format": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", - "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyrainbow": "^1.2.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/runner/node_modules/@vitest/utils": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", - "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "2.1.9", - "loupe": "^3.1.2", - "tinyrainbow": "^1.2.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/runner/node_modules/pathe": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", - "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@vitest/runner/node_modules/tinyrainbow": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", - "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@vitest/snapshot": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", - "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "2.1.9", - "magic-string": "^0.30.12", - "pathe": "^1.1.2" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/snapshot/node_modules/@vitest/pretty-format": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", - "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyrainbow": "^1.2.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/snapshot/node_modules/pathe": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", - "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@vitest/snapshot/node_modules/tinyrainbow": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", - "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, "node_modules/@vitest/spy": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", @@ -21390,16 +21092,6 @@ "url": "https://dotenvx.com" } }, - "node_modules/cac": { - "version": "6.7.14", - "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", - "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/cacache": { "version": "18.0.4", "resolved": "https://registry.npmjs.org/cacache/-/cacache-18.0.4.tgz", @@ -22842,27 +22534,6 @@ "node": ">=4" } }, - "node_modules/cssstyle": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", - "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@asamuzakjp/css-color": "^3.2.0", - "rrweb-cssom": "^0.8.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/cssstyle/node_modules/rrweb-cssom": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", - "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", - "dev": true, - "license": "MIT" - }, "node_modules/csstype": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", @@ -22890,57 +22561,6 @@ "node": ">=8" } }, - "node_modules/data-urls": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", - "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", - "dev": true, - "license": "MIT", - "dependencies": { - "whatwg-mimetype": "^4.0.0", - "whatwg-url": "^14.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/data-urls/node_modules/tr46": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", - "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", - "dev": true, - "license": "MIT", - "dependencies": { - "punycode": "^2.3.1" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/data-urls/node_modules/webidl-conversions": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", - "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=12" - } - }, - "node_modules/data-urls/node_modules/whatwg-url": { - "version": "14.2.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", - "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", - "dev": true, - "license": "MIT", - "dependencies": { - "tr46": "^5.1.0", - "webidl-conversions": "^7.0.0" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/data-view-buffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", @@ -23061,13 +22681,6 @@ "node": ">=0.10.0" } }, - "node_modules/decimal.js": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", - "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", - "dev": true, - "license": "MIT" - }, "node_modules/decode-uri-component": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.4.1.tgz", @@ -24092,13 +23705,6 @@ "node": ">= 0.4" } }, - "node_modules/es-module-lexer": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", - "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", - "dev": true, - "license": "MIT" - }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -24994,16 +24600,6 @@ "node": ">=4.0" } }, - "node_modules/estree-walker": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0" - } - }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -25109,16 +24705,6 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/expect-type": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", - "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.0.0" - } - }, "node_modules/exponential-backoff": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.2.tgz", @@ -26675,19 +26261,6 @@ "node": ">=10" } }, - "node_modules/html-encoding-sniffer": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", - "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "whatwg-encoding": "^3.1.1" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -27511,13 +27084,6 @@ "node": ">=0.10.0" } }, - "node_modules/is-potential-custom-element-name": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", - "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", - "dev": true, - "license": "MIT" - }, "node_modules/is-promise": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.2.2.tgz", @@ -29934,130 +29500,6 @@ "js-yaml": "bin/js-yaml.js" } }, - "node_modules/jsdom": { - "version": "25.0.1", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-25.0.1.tgz", - "integrity": "sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==", - "dev": true, - "license": "MIT", - "dependencies": { - "cssstyle": "^4.1.0", - "data-urls": "^5.0.0", - "decimal.js": "^10.4.3", - "form-data": "^4.0.0", - "html-encoding-sniffer": "^4.0.0", - "http-proxy-agent": "^7.0.2", - "https-proxy-agent": "^7.0.5", - "is-potential-custom-element-name": "^1.0.1", - "nwsapi": "^2.2.12", - "parse5": "^7.1.2", - "rrweb-cssom": "^0.7.1", - "saxes": "^6.0.0", - "symbol-tree": "^3.2.4", - "tough-cookie": "^5.0.0", - "w3c-xmlserializer": "^5.0.0", - "webidl-conversions": "^7.0.0", - "whatwg-encoding": "^3.1.1", - "whatwg-mimetype": "^4.0.0", - "whatwg-url": "^14.0.0", - "ws": "^8.18.0", - "xml-name-validator": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "canvas": "^2.11.2" - }, - "peerDependenciesMeta": { - "canvas": { - "optional": true - } - } - }, - "node_modules/jsdom/node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, - "node_modules/jsdom/node_modules/https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/jsdom/node_modules/tr46": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", - "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", - "dev": true, - "license": "MIT", - "dependencies": { - "punycode": "^2.3.1" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/jsdom/node_modules/webidl-conversions": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", - "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=12" - } - }, - "node_modules/jsdom/node_modules/whatwg-url": { - "version": "14.2.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", - "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", - "dev": true, - "license": "MIT", - "dependencies": { - "tr46": "^5.1.0", - "webidl-conversions": "^7.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/jsdom/node_modules/ws": { - "version": "8.18.3", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", - "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, "node_modules/jsep": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/jsep/-/jsep-1.4.0.tgz", @@ -31654,16 +31096,6 @@ "node": ">=12" } }, - "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - }, "node_modules/make-dir": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", @@ -33057,13 +32489,6 @@ "node": ">=8" } }, - "node_modules/nwsapi": { - "version": "2.2.23", - "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.23.tgz", - "integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==", - "dev": true, - "license": "MIT" - }, "node_modules/nx": { "version": "20.3.2", "resolved": "https://registry.npmjs.org/nx/-/nx-20.3.2.tgz", @@ -34150,32 +33575,6 @@ "parse-path": "^7.0.0" } }, - "node_modules/parse5": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", - "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", - "dev": true, - "license": "MIT", - "dependencies": { - "entities": "^6.0.0" - }, - "funding": { - "url": "https://github.com/inikulin/parse5?sponsor=1" - } - }, - "node_modules/parse5/node_modules/entities": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", - "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -35800,13 +35199,6 @@ "fsevents": "~2.3.2" } }, - "node_modules/rrweb-cssom": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz", - "integrity": "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==", - "dev": true, - "license": "MIT" - }, "node_modules/run-async": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/run-async/-/run-async-2.4.1.tgz", @@ -35974,19 +35366,6 @@ "integrity": "sha512-8I2a3LovHTOpm7NV5yOyO8IHqgVsfK4+UuySrXU8YXkSRX7k6hCV9b3HrkKCr3nMpgj+0bmocaJJWpvp1oc7ZA==", "dev": true }, - "node_modules/saxes": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", - "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", - "dev": true, - "license": "ISC", - "dependencies": { - "xmlchars": "^2.2.0" - }, - "engines": { - "node": ">=v12.22.7" - } - }, "node_modules/scheduler": { "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", @@ -36952,13 +36331,6 @@ "resolved": "https://registry.npmjs.org/sift/-/sift-16.0.1.tgz", "integrity": "sha512-Wv6BjQ5zbhW7VFefWusVP33T/EM0vYikCaQ2qR8yULbsilAT8/wQaXvuQ3ptGLpoKx+lihJE3y2UTgKDyyNHZQ==" }, - "node_modules/siginfo": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", - "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", - "dev": true, - "license": "ISC" - }, "node_modules/signal-exit": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", @@ -37362,13 +36734,6 @@ "node": ">=8" } }, - "node_modules/stackback": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", - "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", - "dev": true, - "license": "MIT" - }, "node_modules/statuses": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", @@ -37377,13 +36742,6 @@ "node": ">= 0.8" } }, - "node_modules/std-env": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", - "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", - "dev": true, - "license": "MIT" - }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", @@ -37922,13 +37280,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/symbol-tree": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", - "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", - "dev": true, - "license": "MIT" - }, "node_modules/synckit": { "version": "0.11.11", "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.11.tgz", @@ -38355,13 +37706,6 @@ "node": ">=0.12" } }, - "node_modules/tinybench": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", - "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", - "dev": true, - "license": "MIT" - }, "node_modules/tinycolor2": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz", @@ -38374,16 +37718,6 @@ "integrity": "sha512-5uC6DDlmeqiOwCPmK9jMSdOuZTh8bU39Ys6yidB+UTt5hfZUPGAypSgFRiEp+jbi9qH40BLDvy85jIU88wKSqw==", "dev": true }, - "node_modules/tinypool": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", - "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.0.0 || >=20.0.0" - } - }, "node_modules/tinyrainbow": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", @@ -38402,26 +37736,6 @@ "node": ">=14.0.0" } }, - "node_modules/tldts": { - "version": "6.1.86", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", - "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "tldts-core": "^6.1.86" - }, - "bin": { - "tldts": "bin/cli.js" - } - }, - "node_modules/tldts-core": { - "version": "6.1.86", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", - "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", - "dev": true, - "license": "MIT" - }, "node_modules/tmp": { "version": "0.0.33", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", @@ -38488,19 +37802,6 @@ "url": "https://github.com/sponsors/Borewit" } }, - "node_modules/tough-cookie": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", - "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "tldts": "^6.1.32" - }, - "engines": { - "node": ">=16" - } - }, "node_modules/tr46": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", @@ -39767,250 +39068,6 @@ } } }, - "node_modules/vite-node": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", - "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", - "dev": true, - "license": "MIT", - "dependencies": { - "cac": "^6.7.14", - "debug": "^4.3.7", - "es-module-lexer": "^1.5.4", - "pathe": "^1.1.2", - "vite": "^5.0.0" - }, - "bin": { - "vite-node": "vite-node.mjs" - }, - "engines": { - "node": "^18.0.0 || >=20.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/vite-node/node_modules/pathe": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", - "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/vitest": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", - "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/expect": "2.1.9", - "@vitest/mocker": "2.1.9", - "@vitest/pretty-format": "^2.1.9", - "@vitest/runner": "2.1.9", - "@vitest/snapshot": "2.1.9", - "@vitest/spy": "2.1.9", - "@vitest/utils": "2.1.9", - "chai": "^5.1.2", - "debug": "^4.3.7", - "expect-type": "^1.1.0", - "magic-string": "^0.30.12", - "pathe": "^1.1.2", - "std-env": "^3.8.0", - "tinybench": "^2.9.0", - "tinyexec": "^0.3.1", - "tinypool": "^1.0.1", - "tinyrainbow": "^1.2.0", - "vite": "^5.0.0", - "vite-node": "2.1.9", - "why-is-node-running": "^2.3.0" - }, - "bin": { - "vitest": "vitest.mjs" - }, - "engines": { - "node": "^18.0.0 || >=20.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "@edge-runtime/vm": "*", - "@types/node": "^18.0.0 || >=20.0.0", - "@vitest/browser": "2.1.9", - "@vitest/ui": "2.1.9", - "happy-dom": "*", - "jsdom": "*" - }, - "peerDependenciesMeta": { - "@edge-runtime/vm": { - "optional": true - }, - "@types/node": { - "optional": true - }, - "@vitest/browser": { - "optional": true - }, - "@vitest/ui": { - "optional": true - }, - "happy-dom": { - "optional": true - }, - "jsdom": { - "optional": true - } - } - }, - "node_modules/vitest/node_modules/@vitest/expect": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", - "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/spy": "2.1.9", - "@vitest/utils": "2.1.9", - "chai": "^5.1.2", - "tinyrainbow": "^1.2.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/vitest/node_modules/@vitest/pretty-format": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", - "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyrainbow": "^1.2.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/vitest/node_modules/@vitest/spy": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", - "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyspy": "^3.0.2" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/vitest/node_modules/@vitest/utils": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", - "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "2.1.9", - "loupe": "^3.1.2", - "tinyrainbow": "^1.2.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/vitest/node_modules/assertion-error": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", - "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/chai": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", - "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", - "dev": true, - "license": "MIT", - "dependencies": { - "assertion-error": "^2.0.1", - "check-error": "^2.1.1", - "deep-eql": "^5.0.1", - "loupe": "^3.1.0", - "pathval": "^2.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/check-error": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz", - "integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 16" - } - }, - "node_modules/vitest/node_modules/deep-eql": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", - "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/vitest/node_modules/pathe": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", - "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/vitest/node_modules/pathval": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", - "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14.16" - } - }, - "node_modules/vitest/node_modules/tinyexec": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", - "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", - "dev": true, - "license": "MIT" - }, - "node_modules/vitest/node_modules/tinyrainbow": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", - "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/vitest/node_modules/tinyspy": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", - "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, "node_modules/vscode-json-languageservice": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/vscode-json-languageservice/-/vscode-json-languageservice-4.2.1.tgz", @@ -40043,19 +39100,6 @@ "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==" }, - "node_modules/w3c-xmlserializer": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", - "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", - "dev": true, - "license": "MIT", - "dependencies": { - "xml-name-validator": "^5.0.0" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/walk-up-path": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/walk-up-path/-/walk-up-path-3.0.1.tgz", @@ -40084,42 +39128,6 @@ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" }, - "node_modules/whatwg-encoding": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", - "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "iconv-lite": "0.6.3" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/whatwg-encoding/node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "dev": true, - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/whatwg-mimetype": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", - "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, "node_modules/whatwg-url": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", @@ -40234,23 +39242,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/why-is-node-running": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", - "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", - "dev": true, - "license": "MIT", - "dependencies": { - "siginfo": "^2.0.0", - "stackback": "0.0.2" - }, - "bin": { - "why-is-node-running": "cli.js" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/wide-align": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", @@ -40526,16 +39517,6 @@ } } }, - "node_modules/xml-name-validator": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", - "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18" - } - }, "node_modules/xml2js": { "version": "0.6.2", "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz", @@ -40558,13 +39539,6 @@ "node": ">=4.0" } }, - "node_modules/xmlchars": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", - "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", - "dev": true, - "license": "MIT" - }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", @@ -40776,22 +39750,16 @@ "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", - "mongoose": "6.11.6", - "serverless-http": "^3.2.0", - "uuid": "^9.0.1" + "serverless-http": "^3.2.0" }, "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", "supertest": "^7.1.4" } }, @@ -40804,50 +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/ai-agents": { - "name": "@friggframework/ai-agents", - "version": "2.0.0-next.0", - "license": "MIT", - "dependencies": { - "@friggframework/schemas": "^2.0.0-next.0" - }, - "devDependencies": { - "@friggframework/eslint-config": "^2.0.0-next.0", - "@friggframework/prettier-config": "^2.0.0-next.0", - "eslint": "^8.22.0", - "jest": "^29.7.0", - "prettier": "^2.7.1" - }, - "peerDependencies": { - "@ai-sdk/openai": ">=1.0.0", - "@anthropic-ai/claude-agent-sdk": ">=0.1.0", - "ai": ">=4.0.0" - }, - "peerDependenciesMeta": { - "@ai-sdk/openai": { - "optional": true - }, - "@anthropic-ai/claude-agent-sdk": { - "optional": true - }, - "ai": { - "optional": true - } - } - }, "packages/core": { "name": "@friggframework/core", "version": "2.0.0-next.0", @@ -40868,7 +39792,6 @@ "express-async-handler": "^1.2.0", "form-data": "^4.0.0", "fs-extra": "^11.2.0", - "js-yaml": "^4.1.0", "lodash": "4.17.21", "lodash.get": "^4.4.2", "mongoose": "6.11.6", @@ -40892,7 +39815,6 @@ "prettier": "^2.7.1", "prisma": "^6.17.0", "sinon": "^16.1.1", - "supertest": "^7.1.4", "typescript": "^5.0.2" }, "peerDependencies": { @@ -40981,7 +39903,6 @@ "@friggframework/prettier-config": "^2.0.0-next.0", "aws-sdk-client-mock": "^4.1.0", "aws-sdk-client-mock-jest": "^4.1.0", - "exit-x": "^0.2.2", "jest": "^30.1.3", "osls": "^3.40.1", "prettier": "^2.7.1", @@ -42148,71 +41069,6 @@ "node": ">=12" } }, - "packages/e2e": { - "name": "@friggframework/e2e", - "version": "1.0.0", - "license": "MIT", - "dependencies": { - "@friggframework/core": "*" - }, - "devDependencies": { - "@friggframework/test": "*", - "jest": "^29.7.0", - "mongodb-memory-server": "^8.9.0", - "supertest": "^6.3.3" - } - }, - "packages/e2e/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" - } - }, - "packages/e2e/node_modules/superagent": { - "version": "8.1.2", - "resolved": "https://registry.npmjs.org/superagent/-/superagent-8.1.2.tgz", - "integrity": "sha512-6WTxW1EB6yCxV5VFOIPQruWGHqc3yI7hEmZK6h+pyk69Lk/Ut7rLUY6W/ONF2MjBuGjvmMiIpsrVJ2vjrHlslA==", - "deprecated": "Please upgrade to superagent v10.2.2+, see release notes at https://github.com/forwardemail/superagent/releases/tag/v10.2.2 - maintenance is supported by Forward Email @ https://forwardemail.net", - "dev": true, - "license": "MIT", - "dependencies": { - "component-emitter": "^1.3.0", - "cookiejar": "^2.1.4", - "debug": "^4.3.4", - "fast-safe-stringify": "^2.1.1", - "form-data": "^4.0.0", - "formidable": "^2.1.2", - "methods": "^1.1.2", - "mime": "2.6.0", - "qs": "^6.11.0", - "semver": "^7.3.8" - }, - "engines": { - "node": ">=6.4.0 <13 || >=14" - } - }, - "packages/e2e/node_modules/supertest": { - "version": "6.3.4", - "resolved": "https://registry.npmjs.org/supertest/-/supertest-6.3.4.tgz", - "integrity": "sha512-erY3HFDG0dPnhw4U+udPfrzXa4xhSG+n4rxfRuZWCUvjFWwKl+OxWf/7zk50s84/fAAs7vf5QAb9uRa0cCykxw==", - "deprecated": "Please upgrade to supertest v7.1.3+, see release notes at https://github.com/forwardemail/supertest/releases/tag/v7.1.3 - maintenance is supported by Forward Email @ https://forwardemail.net", - "dev": true, - "license": "MIT", - "dependencies": { - "methods": "^1.1.2", - "superagent": "^8.1.2" - }, - "engines": { - "node": ">=6.4.0" - } - }, "packages/eslint-config": { "name": "@friggframework/eslint-config", "version": "2.0.0-next.0", @@ -42295,14 +41151,12 @@ "license": "MIT", "dependencies": { "@babel/eslint-parser": "^7.18.9", - "@hapi/boom": "^10.0.1", "eslint": "^8.22.0", "eslint-config-prettier": "^8.5.0", "eslint-plugin-json": "^3.1.0", "eslint-plugin-markdown": "^3.0.0", "eslint-plugin-no-only-tests": "^3.0.0", "eslint-plugin-yaml": "^0.5.0", - "express": "^4.21.2", "jest-runner-groups": "^2.2.0", "mongodb-memory-server": "^8.9.0", "open": "^8.4.2" @@ -42311,67 +41165,7 @@ "@friggframework/eslint-config": "^2.0.0-next.0", "@friggframework/prettier-config": "^2.0.0-next.0", "jest": "^29.7.0", - "prettier": "^2.7.1", - "supertest": "^6.3.3" - }, - "peerDependencies": { - "supertest": ">=6.0.0" - }, - "peerDependenciesMeta": { - "supertest": { - "optional": true - } - } - }, - "packages/test/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" - } - }, - "packages/test/node_modules/superagent": { - "version": "8.1.2", - "resolved": "https://registry.npmjs.org/superagent/-/superagent-8.1.2.tgz", - "integrity": "sha512-6WTxW1EB6yCxV5VFOIPQruWGHqc3yI7hEmZK6h+pyk69Lk/Ut7rLUY6W/ONF2MjBuGjvmMiIpsrVJ2vjrHlslA==", - "deprecated": "Please upgrade to superagent v10.2.2+, see release notes at https://github.com/forwardemail/superagent/releases/tag/v10.2.2 - maintenance is supported by Forward Email @ https://forwardemail.net", - "dev": true, - "license": "MIT", - "dependencies": { - "component-emitter": "^1.3.0", - "cookiejar": "^2.1.4", - "debug": "^4.3.4", - "fast-safe-stringify": "^2.1.1", - "form-data": "^4.0.0", - "formidable": "^2.1.2", - "methods": "^1.1.2", - "mime": "2.6.0", - "qs": "^6.11.0", - "semver": "^7.3.8" - }, - "engines": { - "node": ">=6.4.0 <13 || >=14" - } - }, - "packages/test/node_modules/supertest": { - "version": "6.3.4", - "resolved": "https://registry.npmjs.org/supertest/-/supertest-6.3.4.tgz", - "integrity": "sha512-erY3HFDG0dPnhw4U+udPfrzXa4xhSG+n4rxfRuZWCUvjFWwKl+OxWf/7zk50s84/fAAs7vf5QAb9uRa0cCykxw==", - "deprecated": "Please upgrade to supertest v7.1.3+, see release notes at https://github.com/forwardemail/supertest/releases/tag/v7.1.3 - maintenance is supported by Forward Email @ https://forwardemail.net", - "dev": true, - "license": "MIT", - "dependencies": { - "methods": "^1.1.2", - "superagent": "^8.1.2" - }, - "engines": { - "node": ">=6.4.0" + "prettier": "^2.7.1" } }, "packages/ui": { @@ -42404,11 +41198,9 @@ "eslint-plugin-react": "^7.34.3", "eslint-plugin-react-hooks": "^4.6.2", "eslint-plugin-react-refresh": "^0.4.7", - "jsdom": "^25.0.0", "postcss": "^8.4.41", "tailwindcss": "^3.4.10", - "vite": "^5.3.4", - "vitest": "^2.1.0" + "vite": "^5.3.4" } }, "packages/ui/node_modules/node-fetch": { diff --git a/packages/admin-scripts/index.js b/packages/admin-scripts/index.js index 9589f8e20..b5e379910 100644 --- a/packages/admin-scripts/index.js +++ b/packages/admin-scripts/index.js @@ -8,11 +8,17 @@ // 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 -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'); @@ -30,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 = { @@ -39,13 +44,16 @@ module.exports = { ScriptFactory, getScriptFactory, createScriptFactory, + AdminScriptContext, + createAdminScriptContext, + // Legacy aliases (deprecated) AdminFriggCommands, createAdminFriggCommands, ScriptRunner, createScriptRunner, // Infrastructure layer - adminAuthMiddleware, + validateAdminApiKey, router, app, routerHandler, @@ -62,5 +70,4 @@ module.exports = { AWSSchedulerAdapter, LocalSchedulerAdapter, createSchedulerAdapter, - detectSchedulerAdapterType, }; diff --git a/packages/admin-scripts/package.json b/packages/admin-scripts/package.json index e20c83d34..254797d22 100644 --- a/packages/admin-scripts/package.json +++ b/packages/admin-scripts/package.json @@ -6,22 +6,16 @@ "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", - "mongoose": "6.11.6", - "serverless-http": "^3.2.0", - "uuid": "^9.0.1" + "serverless-http": "^3.2.0" }, "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", "supertest": "^7.1.4" }, "scripts": { 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..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 @@ -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', @@ -181,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()', () => { @@ -301,9 +363,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__/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/__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..dfe64e275 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; } @@ -53,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, @@ -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', @@ -69,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/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__/admin-frigg-commands.test.js b/packages/admin-scripts/src/application/__tests__/admin-frigg-commands.test.js index c8966fadb..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/script-execution-repository-factory'); jest.mock('@friggframework/core/queues'); -describe('AdminFriggCommands', () => { +describe('AdminScriptContext', () => { 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(), @@ -46,302 +42,118 @@ describe('AdminFriggCommands', () => { 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' }); + 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 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(); + 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.requiresIntegrationFactory = true' + '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.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() 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.scriptExecutionRepository; - mockScriptExecutionRepo.appendExecutionLog.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 18a955403..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 @@ -28,12 +28,11 @@ describe('AdminScriptBase', () => { config: { timeout: 600000, maxRetries: 3, - requiresIntegrationFactory: true, + requireIntegrationInstance: true, }, display: { - label: 'Test Script', - description: 'For testing', category: 'testing', + icon: 'test-icon', }, }; } @@ -45,50 +44,12 @@ describe('AdminScriptBase', () => { 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', - }); + it('should have clean display object without redundant fields', () => { + expect(AdminScriptBase.Definition.display).toBeDefined(); + expect(AdminScriptBase.Definition.display.category).toBe('maintenance'); + expect(AdminScriptBase.Definition.display.label).toBeUndefined(); + expect(AdminScriptBase.Definition.display.description).toBeUndefined(); }); }); @@ -96,12 +57,18 @@ describe('AdminScriptBase', () => { 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 +84,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 +103,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,128 +116,83 @@ 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', version: '1.0.0', description: 'My test script', config: { - requiresIntegrationFactory: true, + requireIntegrationInstance: true, }, }; - 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/__tests__/script-runner.test.js b/packages/admin-scripts/src/application/__tests__/script-runner.test.js index 7cf30abe1..f89f411b1 100644 --- a/packages/admin-scripts/src/application/__tests__/script-runner.test.js +++ b/packages/admin-scripts/src/application/__tests__/script-runner.test.js @@ -23,11 +23,11 @@ describe('ScriptRunner', () => { config: { timeout: 300000, maxRetries: 0, - requiresIntegrationFactory: false, + requireIntegrationInstance: false, }, }; - async execute(frigg, params) { + async execute(params) { return { success: true, params }; } } @@ -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,15 +85,15 @@ 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', + state: 'COMPLETED', output: { success: true, params: { foo: 'bar' } }, metrics: expect.objectContaining({ durationMs: expect.any(Number), @@ -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 = { @@ -128,10 +144,10 @@ 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', + state: 'FAILED', error: expect.objectContaining({ message: 'Script failed', }), @@ -146,7 +162,7 @@ describe('ScriptRunner', () => { version: '1.0.0', description: 'Integration script', config: { - requiresIntegrationFactory: true, + requireIntegrationInstance: true, }, }; @@ -178,8 +194,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/__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/admin-frigg-commands.js b/packages/admin-scripts/src/application/admin-frigg-commands.js index df71f57c3..c3a51dc85 100644 --- a/packages/admin-scripts/src/application/admin-frigg-commands.js +++ b/packages/admin-scripts/src/application/admin-frigg-commands.js @@ -1,18 +1,21 @@ const { QueuerUtil } = require('@friggframework/core/queues'); /** - * AdminFriggCommands + * AdminScriptContext - Execution environment for admin scripts * - * Helper API for admin scripts. Provides: - * - Database access via repositories - * - Integration instantiation (optional) - * - Logging utilities - * - Queue operations for self-queuing pattern + * Provides a controlled surface area for scripts to interact with + * the Frigg platform. Unique capabilities vs direct repo access: * - * Follows lazy-loading pattern for repositories to avoid circular dependencies - * and unnecessary initialization. + * - **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 AdminFriggCommands { +class AdminScriptContext { constructor(params = {}) { this.executionId = params.executionId || null; this.logs = []; @@ -25,7 +28,6 @@ class AdminFriggCommands { this._userRepository = null; this._moduleRepository = null; this._credentialRepository = null; - this._scriptExecutionRepository = null; } // ==================== LAZY-LOADED REPOSITORIES ==================== @@ -62,76 +64,6 @@ 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(); - } - 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 ==================== /** @@ -142,7 +74,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({ @@ -151,12 +83,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 +104,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) { @@ -206,13 +131,6 @@ class AdminFriggCommands { 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; } @@ -230,13 +148,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 93ead1af1..2aaa50f50 100644 --- a/packages/admin-scripts/src/application/admin-script-base.js +++ b/packages/admin-scripts/src/application/admin-script-base.js @@ -1,138 +1,39 @@ -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 + 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, - requiresIntegrationFactory: 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, }, }; - 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.context = params.context || null; 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) { + async execute(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-runner.js b/packages/admin-scripts/src/application/script-runner.js index 83dfa9e92..75158881b 100644 --- a/packages/admin-scripts/src/application/script-runner.js +++ b/packages/admin-scripts/src/application/script-runner.js @@ -1,16 +1,13 @@ 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'); -const { wrapAdminFriggCommandsForDryRun } = require('./dry-run-repository-wrapper'); -const { createDryRunHttpClient, injectDryRunHttpClient } = require('./dry-run-http-interceptor'); /** * Script Runner * * Orchestrates script execution with: * - Execution record creation - * - Script instantiation - * - AdminFriggCommands injection + * - Script instantiation with context injection * - Error handling * - Status updates */ @@ -29,18 +26,23 @@ 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 {boolean} options.dryRun - Execute in dry-run mode (no writes, log operations) + * @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. */ async execute(scriptName, params = {}, options = {}) { - const { trigger = 'MANUAL', audit = {}, executionId: existingExecutionId, dryRun = false } = options; + const { trigger, audit = {}, executionId: existingExecutionId } = options; + + if (!trigger) { + throw new Error('options.trigger is required (MANUAL | SCHEDULED | QUEUE)'); + } // Get script class const scriptClass = this.scriptFactory.get(scriptName); 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` ); @@ -50,7 +52,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, @@ -64,67 +66,37 @@ class ScriptRunner { const startTime = new Date(); try { - // Update status to RUNNING (skip in dry-run) - if (!dryRun) { - await this.commands.updateScriptExecutionStatus(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, - }); - } - - // Create script instance + await this.commands.updateAdminProcessState(executionId, 'RUNNING'); + + // Create context for the script (facade over repositories, queue, logging) + const context = createAdminScriptContext({ + executionId, + integrationFactory: this.integrationFactory, + }); + + // 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(); const durationMs = endTime - startTime; - // 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 }, - }; - } + 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.completeScriptExecution(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, @@ -168,83 +135,6 @@ 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/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/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/builtins/__tests__/integration-health-check.test.js b/packages/admin-scripts/src/builtins/__tests__/integration-health-check.test.js index f9422e12e..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 @@ -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', () => { @@ -37,27 +37,35 @@ 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(), + 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 () => { - mockFrigg.listIntegrations.mockResolvedValue([]); + mockContext.integrationRepository.findIntegrations.mockResolvedValue([]); - const result = await script.execute(mockFrigg, {}); + const result = await script.execute({}); expect(result.healthy).toBe(0); expect(result.unhealthy).toBe(0); @@ -85,10 +93,10 @@ describe('IntegrationHealthCheckScript', () => { } }; - mockFrigg.listIntegrations.mockResolvedValue([integration]); - mockFrigg.instantiate.mockResolvedValue(mockInstance); + mockContext.integrationRepository.findIntegrations.mockResolvedValue([integration]); + mockContext.instantiate.mockResolvedValue(mockInstance); - const result = await script.execute(mockFrigg, { + const result = await script.execute({ checkCredentials: true, checkConnectivity: true }); @@ -112,9 +120,9 @@ describe('IntegrationHealthCheckScript', () => { } }; - mockFrigg.listIntegrations.mockResolvedValue([integration]); + mockContext.integrationRepository.findIntegrations.mockResolvedValue([integration]); - const result = await script.execute(mockFrigg, { + const result = await script.execute({ checkCredentials: true, checkConnectivity: false }); @@ -141,9 +149,9 @@ describe('IntegrationHealthCheckScript', () => { } }; - mockFrigg.listIntegrations.mockResolvedValue([integration]); + mockContext.integrationRepository.findIntegrations.mockResolvedValue([integration]); - const result = await script.execute(mockFrigg, { + const result = await script.execute({ checkCredentials: true, checkConnectivity: false }); @@ -176,10 +184,10 @@ describe('IntegrationHealthCheckScript', () => { } }; - mockFrigg.listIntegrations.mockResolvedValue([integration]); - mockFrigg.instantiate.mockResolvedValue(mockInstance); + mockContext.integrationRepository.findIntegrations.mockResolvedValue([integration]); + mockContext.instantiate.mockResolvedValue(mockInstance); - const result = await script.execute(mockFrigg, { + const result = await script.execute({ checkCredentials: true, checkConnectivity: true }); @@ -209,18 +217,18 @@ describe('IntegrationHealthCheckScript', () => { } }; - mockFrigg.listIntegrations.mockResolvedValue([integration]); - mockFrigg.instantiate.mockResolvedValue(mockInstance); - mockFrigg.updateIntegrationStatus.mockResolvedValue(undefined); + mockContext.integrationRepository.findIntegrations.mockResolvedValue([integration]); + mockContext.instantiate.mockResolvedValue(mockInstance); + mockContext.integrationRepository.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.integrationRepository.updateIntegrationStatus).toHaveBeenCalledWith('int-1', 'ACTIVE'); }); it('should update integration status to ERROR for unhealthy integrations', async () => { @@ -232,17 +240,17 @@ describe('IntegrationHealthCheckScript', () => { } }; - mockFrigg.listIntegrations.mockResolvedValue([integration]); - mockFrigg.updateIntegrationStatus.mockResolvedValue(undefined); + mockContext.integrationRepository.findIntegrations.mockResolvedValue([integration]); + mockContext.integrationRepository.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.integrationRepository.updateIntegrationStatus).toHaveBeenCalledWith('int-1', 'ERROR'); }); it('should not update status when updateStatus is false', async () => { @@ -265,16 +273,16 @@ describe('IntegrationHealthCheckScript', () => { } }; - mockFrigg.listIntegrations.mockResolvedValue([integration]); - mockFrigg.instantiate.mockResolvedValue(mockInstance); + mockContext.integrationRepository.findIntegrations.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.integrationRepository.updateIntegrationStatus).not.toHaveBeenCalled(); }); it('should handle status update failures gracefully', async () => { @@ -297,18 +305,18 @@ describe('IntegrationHealthCheckScript', () => { } }; - mockFrigg.listIntegrations.mockResolvedValue([integration]); - mockFrigg.instantiate.mockResolvedValue(mockInstance); - mockFrigg.updateIntegrationStatus.mockRejectedValue(new Error('Update failed')); + mockContext.integrationRepository.findIntegrations.mockResolvedValue([integration]); + mockContext.instantiate.mockResolvedValue(mockInstance); + mockContext.integrationRepository.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 +333,21 @@ describe('IntegrationHealthCheckScript', () => { config: { type: 'salesforce', credentials: { access_token: 'token2' } } }; - mockFrigg.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')); }); - 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.integrationRepository.findIntegrationById).toHaveBeenCalledWith('int-1'); + expect(mockContext.integrationRepository.findIntegrationById).toHaveBeenCalledWith('int-2'); + expect(mockContext.integrationRepository.findIntegrations).not.toHaveBeenCalled(); expect(result.results).toHaveLength(2); }); @@ -355,10 +363,10 @@ describe('IntegrationHealthCheckScript', () => { } }; - mockFrigg.listIntegrations.mockResolvedValue([integration]); - mockFrigg.instantiate.mockRejectedValue(new Error('Instantiation failed')); + mockContext.integrationRepository.findIntegrations.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 +393,10 @@ describe('IntegrationHealthCheckScript', () => { } }; - mockFrigg.listIntegrations.mockResolvedValue([integration]); - mockFrigg.instantiate.mockResolvedValue(mockInstance); + mockContext.integrationRepository.findIntegrations.mockResolvedValue([integration]); + mockContext.instantiate.mockResolvedValue(mockInstance); - const result = await script.execute(mockFrigg, { + const result = await script.execute({ checkCredentials: false, checkConnectivity: true }); @@ -409,16 +417,16 @@ describe('IntegrationHealthCheckScript', () => { } }; - mockFrigg.listIntegrations.mockResolvedValue([integration]); + mockContext.integrationRepository.findIntegrations.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 +434,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 +505,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 +529,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 +552,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 +572,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 +595,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 9de4b191a..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 @@ -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', () => { @@ -29,32 +29,42 @@ 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(), + 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 () => { - mockFrigg.listIntegrations.mockResolvedValue([]); + mockContext.integrationRepository.findIntegrations.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 +72,9 @@ describe('OAuthTokenRefreshScript', () => { id: 'int-1', config: {} // No credentials }; - mockFrigg.listIntegrations.mockResolvedValue([integration]); + mockContext.integrationRepository.findIntegrations.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 +95,9 @@ describe('OAuthTokenRefreshScript', () => { } } }; - mockFrigg.listIntegrations.mockResolvedValue([integration]); + mockContext.integrationRepository.findIntegrations.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 +118,9 @@ describe('OAuthTokenRefreshScript', () => { } } }; - mockFrigg.listIntegrations.mockResolvedValue([integration]); + mockContext.integrationRepository.findIntegrations.mockResolvedValue([integration]); - const result = await script.execute(mockFrigg, { + const result = await script.execute({ expiryThresholdHours: 24 }); @@ -142,10 +152,10 @@ describe('OAuthTokenRefreshScript', () => { } }; - mockFrigg.listIntegrations.mockResolvedValue([integration]); - mockFrigg.instantiate.mockResolvedValue(mockInstance); + mockContext.integrationRepository.findIntegrations.mockResolvedValue([integration]); + mockContext.instantiate.mockResolvedValue(mockInstance); - const result = await script.execute(mockFrigg, { + const result = await script.execute({ expiryThresholdHours: 24 }); @@ -170,16 +180,16 @@ describe('OAuthTokenRefreshScript', () => { } }; - mockFrigg.listIntegrations.mockResolvedValue([integration]); + mockContext.integrationRepository.findIntegrations.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 +217,10 @@ describe('OAuthTokenRefreshScript', () => { } }; - mockFrigg.listIntegrations.mockResolvedValue([integration]); - mockFrigg.instantiate.mockResolvedValue(mockInstance); + mockContext.integrationRepository.findIntegrations.mockResolvedValue([integration]); + mockContext.instantiate.mockResolvedValue(mockInstance); - const result = await script.execute(mockFrigg, { + const result = await script.execute({ expiryThresholdHours: 24 }); @@ -243,10 +253,10 @@ describe('OAuthTokenRefreshScript', () => { } }; - mockFrigg.listIntegrations.mockResolvedValue([integration]); - mockFrigg.instantiate.mockResolvedValue(mockInstance); + mockContext.integrationRepository.findIntegrations.mockResolvedValue([integration]); + mockContext.instantiate.mockResolvedValue(mockInstance); - const result = await script.execute(mockFrigg, { + const result = await script.execute({ expiryThresholdHours: 24 }); @@ -268,19 +278,19 @@ describe('OAuthTokenRefreshScript', () => { config: { credentials: { access_token: 'token2' } } }; - mockFrigg.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')); }); - 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.integrationRepository.findIntegrationById).toHaveBeenCalledWith('int-1'); + expect(mockContext.integrationRepository.findIntegrationById).toHaveBeenCalledWith('int-2'); + expect(mockContext.integrationRepository.findIntegrations).not.toHaveBeenCalled(); expect(result.details).toHaveLength(2); }); @@ -295,10 +305,10 @@ describe('OAuthTokenRefreshScript', () => { } }; - mockFrigg.listIntegrations.mockResolvedValue([integration]); - mockFrigg.instantiate.mockRejectedValue(new Error('Instantiation failed')); + mockContext.integrationRepository.findIntegrations.mockResolvedValue([integration]); + mockContext.instantiate.mockRejectedValue(new Error('Instantiation failed')); - const result = await script.execute(mockFrigg, { + const result = await script.execute({ expiryThresholdHours: 24 }); @@ -313,14 +323,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 +341,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 147c7dc9e..a6ffe4ea2 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: { @@ -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.integrationRepository.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.integrationRepository.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.integrationRepository.findIntegrations({}); } - 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 6586e8267..6e895b4a6 100644 --- a/packages/admin-scripts/src/builtins/oauth-token-refresh.js +++ b/packages/admin-scripts/src/builtins/oauth-token-refresh.js @@ -47,17 +47,16 @@ 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 }, + // 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.integrationRepository.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.integrationRepository.findIntegrations({}); } - 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); 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..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 @@ -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(), }; @@ -104,7 +99,7 @@ describe('Admin Script Router', () => { version: '1.0.0', description: 'Test script', category: 'test', - requiresIntegrationFactory: false, + requireIntegrationInstance: false, schedule: null, }); }); @@ -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,49 +169,67 @@ describe('Admin Script Router', () => { }); it('should queue script for async execution', async () => { - mockCommands.createScriptExecution.mockResolvedValue({ + process.env.ADMIN_SCRIPT_QUEUE_URL = 'https://sqs.us-east-1.amazonaws.com/123/test-queue'; + 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', }); 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({ 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 () => { - mockCommands.createScriptExecution.mockResolvedValue({ + process.env.ADMIN_SCRIPT_QUEUE_URL = 'https://sqs.us-east-1.amazonaws.com/123/test-queue'; + 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' }, }); expect(response.status).toBe(202); - expect(response.body.status).toBe('PENDING'); + 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 () => { mockFactory.has.mockReturnValue(false); const response = await request(app) - .post('/admin/scripts/non-existent/execute') + .post('/admin/scripts/non-existent') .send({ params: {}, }); @@ -226,15 +239,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 +255,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 +270,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({ @@ -294,8 +311,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'), }; @@ -471,7 +488,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', @@ -491,11 +508,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 () => { @@ -504,14 +521,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) @@ -522,9 +539,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, }); }); @@ -633,7 +650,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, @@ -641,8 +658,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(); @@ -655,7 +672,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, @@ -663,7 +680,7 @@ describe('Admin Script Router', () => { scriptName: 'test-script', enabled: true, cronExpression: '0 12 * * *', - // No awsScheduleArn + // No externalScheduleId }, }); @@ -683,10 +700,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' @@ -695,7 +712,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-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..8c4215049 100644 --- a/packages/admin-scripts/src/infrastructure/admin-script-router.js +++ b/packages/admin-scripts/src/infrastructure/admin-script-router.js @@ -1,28 +1,42 @@ 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 { 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'); -const { ScheduleManagementUseCase } = require('../application/schedule-management-use-case'); +const { + GetEffectiveScheduleUseCase, + UpsertScheduleUseCase, + DeleteScheduleUseCase, +} = require('../application/use-cases'); const router = express.Router(); // Apply auth middleware to all admin routes -router.use(adminAuthMiddleware); +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({ + 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 { + getEffectiveSchedule: new GetEffectiveScheduleUseCase({ commands, scriptFactory }), + upsertSchedule: new UpsertScheduleUseCase({ commands, schedulerAdapter, scriptFactory }), + deleteSchedule: new DeleteScheduleUseCase({ commands, schedulerAdapter, scriptFactory }), + }; } /** @@ -40,8 +54,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, })), }); @@ -87,13 +101,13 @@ router.get('/scripts/:scriptName', async (req, res) => { }); /** - * POST /admin/scripts/:scriptName/execute - * Execute a script (sync, async, or dry-run) + * POST /admin/scripts/:scriptName/validate + * Validate script inputs without executing (dry-run) */ -router.post('/scripts/:scriptName/execute', 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)) { @@ -103,38 +117,56 @@ 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, + 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', mode: 'sync', - audit: req.adminAudit, }); return res.json(result); } // 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.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 @@ -145,12 +177,12 @@ router.post('/scripts/:scriptName/execute', async (req, res) => { trigger: 'MANUAL', params, }, - process.env.ADMIN_SCRIPT_QUEUE_URL + queueUrl ); res.status(202).json({ executionId: execution.id, - status: 'PENDING', + status: 'QUEUED', scriptName, message: 'Script queued for execution', }); @@ -161,14 +193,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 +217,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({ @@ -213,9 +246,9 @@ router.get('/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, @@ -242,9 +275,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, @@ -283,9 +316,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/admin-scripts/src/infrastructure/script-executor-handler.js b/packages/admin-scripts/src/infrastructure/script-executor-handler.js index 41778571d..8caf79369 100644 --- a/packages/admin-scripts/src/infrastructure/script-executor-handler.js +++ b/packages/admin-scripts/src/infrastructure/script-executor-handler.js @@ -4,62 +4,66 @@ const { createAdminScriptCommands } = require('@friggframework/core/application/ /** * 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; + let executionId; try { - const runner = createScriptRunner(); - const commands = createAdminScriptCommands(); + const message = JSON.parse(record.body); + ({ scriptName, executionId } = message); + const { trigger, params } = message; - // If executionId provided (async from API), update existing record - if (executionId) { - await commands.updateScriptExecutionStatus(executionId, 'RUNNING'); + if (!scriptName || !executionId) { + throw new Error(`Invalid SQS message: missing scriptName or executionId`); } + 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); + // 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); - // Try to update execution status if we have an ID + // 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) { - const commands = createAdminScriptCommands(); - await commands - .completeScriptExecution(executionId, { - status: 'FAILED', + try { + 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) - ); + }); + } catch (updateError) { + console.error(`Failed to update execution ${executionId} state:`, updateError); + } } results.push({ - scriptName, + scriptName: scriptName || 'unknown', status: 'FAILED', error: error.message, }); diff --git a/packages/core/admin-scripts/index.js b/packages/core/admin-scripts/index.js index c68d56bf5..7da475971 100644 --- a/packages/core/admin-scripts/index.js +++ b/packages/core/admin-scripts/index.js @@ -9,32 +9,23 @@ * - 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'); +const { AdminProcessRepositoryInterface } = require('./repositories/admin-process-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, - ScriptExecutionRepositoryPostgres, - ScriptExecutionRepositoryDocumentDB, -} = require('./repositories/script-execution-repository-factory'); + createAdminProcessRepository, + AdminProcessRepositoryMongo, + AdminProcessRepositoryPostgres, + AdminProcessRepositoryDocumentDB, +} = require('./repositories/admin-process-repository-factory'); const { createScriptScheduleRepository, ScriptScheduleRepositoryMongo, @@ -44,22 +35,17 @@ const { module.exports = { // Repository Interfaces - AdminApiKeyRepositoryInterface, - ScriptExecutionRepositoryInterface, + AdminProcessRepositoryInterface, ScriptScheduleRepositoryInterface, // Repository Factories (primary exports for use cases) - createAdminApiKeyRepository, - createScriptExecutionRepository, + createAdminProcessRepository, createScriptScheduleRepository, // Concrete Implementations (for testing) - AdminApiKeyRepositoryMongo, - AdminApiKeyRepositoryPostgres, - AdminApiKeyRepositoryDocumentDB, - 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-schedule-repository-interface.test.js b/packages/core/admin-scripts/repositories/__tests__/script-schedule-repository-interface.test.js index c3a9a59b5..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 @@ -1,6 +1,4 @@ -const { - ScriptScheduleRepositoryInterface, -} = require('../script-schedule-repository-interface'); +const { ScriptScheduleRepositoryInterface } = require('../script-schedule-repository-interface'); describe('ScriptScheduleRepositoryInterface', () => { let repository; @@ -13,9 +11,7 @@ describe('ScriptScheduleRepositoryInterface', () => { 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' - ); + ).rejects.toThrow('Method findScheduleByScriptName must be implemented by subclass'); }); it('should throw error when upsertSchedule is not implemented', async () => { @@ -26,54 +22,40 @@ describe('ScriptScheduleRepositoryInterface', () => { cronExpression: '0 0 * * *', timezone: 'UTC', }) - ).rejects.toThrow( - 'Method upsertSchedule must be implemented by subclass' - ); + ).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' - ); + ).rejects.toThrow('Method deleteSchedule must be implemented by subclass'); }); it('should throw error when updateScheduleAwsInfo is not implemented', async () => { await expect( repository.updateScheduleAwsInfo('test-script', { - awsScheduleArn: - 'arn:aws:events:us-east-1:123456789012:rule/test-rule', + awsScheduleArn: 'arn:aws:events:us-east-1:123456789012:rule/test-rule', awsScheduleName: 'test-rule', }) - ).rejects.toThrow( - 'Method updateScheduleAwsInfo must be implemented by subclass' - ); + ).rejects.toThrow('Method updateScheduleAwsInfo 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' - ); + 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' - ); + ).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' - ); + await expect( + repository.listSchedules() + ).rejects.toThrow('Method listSchedules must be implemented by subclass'); }); }); @@ -90,8 +72,7 @@ describe('ScriptScheduleRepositoryInterface', () => { enabled: true, cronExpression: '0 0 * * *', timezone: 'America/New_York', - awsScheduleArn: - 'arn:aws:events:us-east-1:123456789012:rule/test', + awsScheduleArn: 'arn:aws:events:us-east-1:123456789012:rule/test', awsScheduleName: 'test-rule', }; @@ -107,8 +88,7 @@ describe('ScriptScheduleRepositoryInterface', () => { it('should accept scriptName and awsInfo in updateScheduleAwsInfo', async () => { await expect( repository.updateScheduleAwsInfo('test-script', { - awsScheduleArn: - 'arn:aws:events:us-east-1:123456789012:rule/test', + awsScheduleArn: 'arn:aws:events:us-east-1:123456789012:rule/test', awsScheduleName: 'test-rule', }) ).rejects.toThrow(); @@ -116,10 +96,7 @@ describe('ScriptScheduleRepositoryInterface', () => { it('should accept scriptName and timestamp in updateScheduleLastTriggered', async () => { await expect( - repository.updateScheduleLastTriggered( - 'test-script', - new Date() - ) + repository.updateScheduleLastTriggered('test-script', new Date()) ).rejects.toThrow(); }); diff --git a/packages/core/admin-scripts/repositories/admin-process-repository-documentdb.js b/packages/core/admin-scripts/repositories/admin-process-repository-documentdb.js new file mode 100644 index 000000000..01f589ac7 --- /dev/null +++ b/packages/core/admin-scripts/repositories/admin-process-repository-documentdb.js @@ -0,0 +1,21 @@ +const { + AdminProcessRepositoryMongo, +} = require('./admin-process-repository-mongo'); + +/** + * DocumentDB Admin Process 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 AdminProcessRepositoryDocumentDB extends AdminProcessRepositoryMongo { + constructor() { + super(); + } +} + +module.exports = { AdminProcessRepositoryDocumentDB }; diff --git a/packages/core/admin-scripts/repositories/admin-process-repository-factory.js b/packages/core/admin-scripts/repositories/admin-process-repository-factory.js new file mode 100644 index 000000000..cfdb946b1 --- /dev/null +++ b/packages/core/admin-scripts/repositories/admin-process-repository-factory.js @@ -0,0 +1,51 @@ +const { AdminProcessRepositoryMongo } = require('./admin-process-repository-mongo'); +const { AdminProcessRepositoryPostgres } = require('./admin-process-repository-postgres'); +const { + AdminProcessRepositoryDocumentDB, +} = require('./admin-process-repository-documentdb'); +const config = require('../../database/config'); + +/** + * Admin Process 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 = createAdminProcessRepository(); + * ``` + * + * @returns {AdminProcessRepositoryInterface} Configured repository adapter + * @throws {Error} If database type is not supported + */ +function createAdminProcessRepository() { + const dbType = config.DB_TYPE; + + switch (dbType) { + case 'mongodb': + return new AdminProcessRepositoryMongo(); + + case 'postgresql': + return new AdminProcessRepositoryPostgres(); + + case 'documentdb': + return new AdminProcessRepositoryDocumentDB(); + + default: + throw new Error( + `Unsupported database type: ${dbType}. Supported values: 'mongodb', 'documentdb', 'postgresql'` + ); + } +} + +module.exports = { + createAdminProcessRepository, + // Export adapters for direct testing + 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-schedule-repository-factory.js b/packages/core/admin-scripts/repositories/script-schedule-repository-factory.js index c529b8098..dc8e44974 100644 --- a/packages/core/admin-scripts/repositories/script-schedule-repository-factory.js +++ b/packages/core/admin-scripts/repositories/script-schedule-repository-factory.js @@ -1,9 +1,5 @@ -const { - ScriptScheduleRepositoryMongo, -} = require('./script-schedule-repository-mongo'); -const { - ScriptScheduleRepositoryPostgres, -} = require('./script-schedule-repository-postgres'); +const { ScriptScheduleRepositoryMongo } = require('./script-schedule-repository-mongo'); +const { ScriptScheduleRepositoryPostgres } = require('./script-schedule-repository-postgres'); const { ScriptScheduleRepositoryDocumentDB, } = require('./script-schedule-repository-documentdb'); 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 4ce09dfbe..24f44e3cf 100644 --- a/packages/core/admin-scripts/repositories/script-schedule-repository-interface.js +++ b/packages/core/admin-scripts/repositories/script-schedule-repository-interface.js @@ -23,9 +23,7 @@ class ScriptScheduleRepositoryInterface { * @abstract */ async findScheduleByScriptName(scriptName) { - throw new Error( - 'Method findScheduleByScriptName must be implemented by subclass' - ); + throw new Error('Method findScheduleByScriptName must be implemented by subclass'); } /** @@ -36,22 +34,13 @@ 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, - }) { - throw new Error( - 'Method upsertSchedule must be implemented by subclass' - ); + async upsertSchedule({ scriptName, enabled, cronExpression, timezone, externalScheduleId, externalScheduleName }) { + throw new Error('Method upsertSchedule must be implemented by subclass'); } /** @@ -62,28 +51,21 @@ class ScriptScheduleRepositoryInterface { * @abstract */ async deleteSchedule(scriptName) { - throw new Error( - 'Method deleteSchedule must be implemented by subclass' - ); + throw new Error('Method deleteSchedule must be implemented by subclass'); } /** - * 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'); } /** @@ -95,9 +77,7 @@ class ScriptScheduleRepositoryInterface { * @abstract */ async updateScheduleLastTriggered(scriptName, timestamp) { - throw new Error( - 'Method updateScheduleLastTriggered must be implemented by subclass' - ); + throw new Error('Method updateScheduleLastTriggered must be implemented by subclass'); } /** @@ -109,9 +89,7 @@ class ScriptScheduleRepositoryInterface { * @abstract */ async updateScheduleNextTrigger(scriptName, timestamp) { - throw new Error( - 'Method updateScheduleNextTrigger must be implemented by subclass' - ); + throw new Error('Method updateScheduleNextTrigger 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 c74c4c614..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,28 +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 }, @@ -105,22 +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 2773152dd..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,28 +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 }, @@ -136,22 +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/__tests__/admin-script-commands.test.js b/packages/core/application/commands/__tests__/admin-script-commands.test.js index ea7a778e3..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,54 +6,34 @@ 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 repository factories - uses interface method names +const mockAdminProcessRepo = { + createProcess: jest.fn(), + findProcessById: jest.fn(), + findProcessesByName: jest.fn(), + findProcessesByState: jest.fn(), + updateProcessState: jest.fn(), + updateProcessResults: jest.fn(), + appendProcessLog: jest.fn(), +}; -// Mock uuid for deterministic key generation -const mockUuid = jest.fn(); -jest.mock('uuid', () => ({ - v4: mockUuid, +jest.mock('../../../admin-scripts/repositories/admin-process-repository-factory', () => ({ + createAdminProcessRepository: () => mockAdminProcessRepo, })); -// 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(), +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/admin-api-key-repository-factory', - () => ({ - createAdminApiKeyRepository: () => mockApiKeyRepo, - }) -); - -jest.mock( - '../../../admin-scripts/repositories/script-execution-repository-factory', - () => ({ - createScriptExecutionRepository: () => mockExecutionRepo, - }) -); +jest.mock('../../../admin-scripts/repositories/script-schedule-repository-factory', () => ({ + createScriptScheduleRepository: () => mockScheduleRepo, +})); const { createAdminScriptCommands } = require('../admin-script-commands'); @@ -65,351 +45,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.createProcess.mockResolvedValue(mockProcess); - const result = await commands.createScriptExecution({ + const result = await commands.createAdminProcess({ scriptName: 'test-script', scriptVersion: '1.0.0', trigger: 'MANUAL', @@ -422,55 +82,71 @@ describe('createAdminScriptCommands', () => { }, }); - 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(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(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.createProcess.mockResolvedValue(mockProcess); - await commands.createScriptExecution({ + await commands.createAdminProcess({ scriptName: 'test', trigger: 'MANUAL', }); - expect(mockExecutionRepo.createExecution).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 () => { - mockExecutionRepo.createExecution.mockResolvedValue({ - id: 'exec-1', - audit: { - apiKeyName: 'Test Key', - apiKeyLast4: 'abcd', - ipAddress: '192.168.1.1', + mockAdminProcessRepo.createProcess.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: { @@ -480,44 +156,43 @@ describe('createAdminScriptCommands', () => { }, }); - expect(mockExecutionRepo.createExecution).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', + }, + }), }) ); }); }); - 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.findProcessById.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.findProcessById).toHaveBeenCalledWith('proc-1'); + expect(result).toEqual(mockProcess); }); it('returns error if not found', async () => { - mockExecutionRepo.findExecutionById.mockResolvedValue(null); + mockAdminProcessRepo.findProcessById.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'); @@ -525,106 +200,113 @@ 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.findProcessesByName.mockResolvedValue( + mockProcesses ); - const result = await commands.findScriptExecutionsByName('test'); + const result = await commands.findAdminProcessesByName('test'); - expect( - mockExecutionRepo.findExecutionsByScriptName - ).toHaveBeenCalledWith('test', {}); - expect(result).toEqual(mockExecutions); + expect(mockAdminProcessRepo.findProcessesByName).toHaveBeenCalledWith( + 'test', + {} + ); + expect(result).toEqual(mockProcesses); }); it('passes options to repository', async () => { - mockExecutionRepo.findExecutionsByScriptName.mockResolvedValue([]); + mockAdminProcessRepo.findProcessesByName.mockResolvedValue([]); - await commands.findScriptExecutionsByName('test', { + await commands.findAdminProcessesByName('test', { limit: 10, offset: 5, sortBy: 'createdAt', sortOrder: 'desc', }); - expect( - mockExecutionRepo.findExecutionsByScriptName - ).toHaveBeenCalledWith('test', { - limit: 10, - offset: 5, - sortBy: 'createdAt', - sortOrder: 'desc', - }); + expect(mockAdminProcessRepo.findProcessesByName).toHaveBeenCalledWith( + 'test', + { + limit: 10, + offset: 5, + sortBy: 'createdAt', + sortOrder: 'desc', + } + ); }); it('returns empty array on error', async () => { - mockExecutionRepo.findExecutionsByScriptName.mockRejectedValue( + mockAdminProcessRepo.findProcessesByName.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.updateProcessState.mockResolvedValue(mockUpdated); - const result = await commands.updateScriptExecutionStatus( - 'exec-1', + const result = await commands.updateAdminProcessState( + 'proc-1', 'RUNNING' ); - expect( - mockExecutionRepo.updateExecutionStatus - ).toHaveBeenCalledWith('exec-1', 'RUNNING'); + expect(mockAdminProcessRepo.updateProcessState).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.updateProcessState.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', @@ -633,22 +315,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.appendProcessLog.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.appendProcessLog).toHaveBeenCalledWith( + 'proc-1', logEntry ); - expect(result.logs).toContain(logEntry); + expect(result.results.logs).toContain(logEntry); }); it('handles different log levels', async () => { @@ -661,82 +346,77 @@ describe('createAdminScriptCommands', () => { timestamp: new Date().toISOString(), }; - mockExecutionRepo.appendExecutionLog.mockResolvedValue({ - id: 'exec-1', - logs: [logEntry], + mockAdminProcessRepo.appendProcessLog.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.appendProcessLog).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, 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.completeScriptExecution('exec-1', { - status: 'COMPLETED', + 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( - 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(mockAdminProcessRepo.updateProcessState).toHaveBeenCalledWith( + 'proc-1', + 'COMPLETED' + ); + expect(mockAdminProcessRepo.updateProcessResults).toHaveBeenCalledWith( + 'proc-1', + expect.objectContaining({ + output: { result: 'success' }, + metrics: expect.objectContaining({ durationMs: 1234 }), + }) ); expect(result).toEqual({ success: true }); }); - it('handles partial updates', async () => { - mockExecutionRepo.updateExecutionStatus.mockResolvedValue({}); + it('handles partial updates - state only', async () => { + mockAdminProcessRepo.updateProcessState.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.updateProcessState).toHaveBeenCalled(); + expect(mockAdminProcessRepo.updateProcessResults).not.toHaveBeenCalled(); }); - it('updates error details on failure', async () => { - mockExecutionRepo.updateExecutionStatus.mockResolvedValue({}); - mockExecutionRepo.updateExecutionError.mockResolvedValue({}); + it('updates error details on failure via updateProcessResults', async () => { + mockAdminProcessRepo.updateProcessState.mockResolvedValue({}); + mockAdminProcessRepo.updateProcessResults.mockResolvedValue({}); - await commands.completeScriptExecution('exec-1', { - status: 'FAILED', + await commands.completeAdminProcess('proc-1', { + state: 'FAILED', error: { name: 'ValidationError', message: 'Invalid input', @@ -744,115 +424,108 @@ describe('createAdminScriptCommands', () => { }, }); - expect(mockExecutionRepo.updateExecutionError).toHaveBeenCalledWith( - 'exec-1', + 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 () => { - mockExecutionRepo.updateExecutionStatus.mockResolvedValue({}); - mockExecutionRepo.updateExecutionOutput.mockResolvedValue({}); + mockAdminProcessRepo.updateProcessState.mockResolvedValue({}); + mockAdminProcessRepo.updateProcessResults.mockResolvedValue({}); - // Test with null - await commands.completeScriptExecution('exec-1', { - status: 'COMPLETED', + // Test with null - output: null should be included in results + await commands.completeAdminProcess('proc-1', { + state: 'COMPLETED', output: null, }); - expect( - mockExecutionRepo.updateExecutionOutput - ).toHaveBeenCalledWith('exec-1', null); + expect(mockAdminProcessRepo.updateProcessResults).toHaveBeenCalledWith( + 'proc-1', + { output: null } + ); jest.clearAllMocks(); - // Test with undefined (should not call update) - await commands.completeScriptExecution('exec-2', { - status: 'COMPLETED', + // Test with undefined (should not include output in results) + mockAdminProcessRepo.updateProcessState.mockResolvedValue({}); + + await commands.completeAdminProcess('proc-2', { + state: 'COMPLETED', // output is undefined }); - expect( - mockExecutionRepo.updateExecutionOutput - ).not.toHaveBeenCalled(); + // No results to update, so updateProcessResults should not be called + expect(mockAdminProcessRepo.updateProcessResults).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.findProcessesByState.mockResolvedValue(mockProcesses); - const result = await commands.findRecentExecutions({ - status: 'FAILED', - }); + const result = await commands.findRecentAdminProcesses({ state: 'FAILED' }); - expect( - mockExecutionRepo.findExecutionsByStatus - ).toHaveBeenCalledWith('FAILED', { - limit: 20, - sortBy: 'createdAt', - sortOrder: 'desc', - }); - expect(result).toEqual(mockExecutions); + expect(mockAdminProcessRepo.findProcessesByState).toHaveBeenCalledWith( + 'FAILED', + { + limit: 20, + sortBy: 'createdAt', + sortOrder: 'desc', + } + ); + expect(result).toEqual(mockProcesses); }); it('uses default limit of 20', async () => { - mockExecutionRepo.findExecutionsByStatus.mockResolvedValue([]); + mockAdminProcessRepo.findProcessesByState.mockResolvedValue([]); - await commands.findRecentExecutions({ status: 'COMPLETED' }); + await commands.findRecentAdminProcesses({ state: 'COMPLETED' }); - expect( - mockExecutionRepo.findExecutionsByStatus - ).toHaveBeenCalledWith( + expect(mockAdminProcessRepo.findProcessesByState).toHaveBeenCalledWith( 'COMPLETED', expect.objectContaining({ limit: 20 }) ); }); it('allows custom limit', async () => { - mockExecutionRepo.findExecutionsByStatus.mockResolvedValue([]); + mockAdminProcessRepo.findProcessesByState.mockResolvedValue([]); - await commands.findRecentExecutions({ - status: 'RUNNING', + await commands.findRecentAdminProcesses({ + state: 'RUNNING', limit: 50, }); - expect( - mockExecutionRepo.findExecutionsByStatus - ).toHaveBeenCalledWith( + expect(mockAdminProcessRepo.findProcessesByState).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.findProcessesByState).not.toHaveBeenCalled(); }); it('returns empty array on error', async () => { - mockExecutionRepo.findExecutionsByStatus.mockRejectedValue( + mockAdminProcessRepo.findProcessesByState.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 c1754b6ac..c2aa2900b 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,166 +17,50 @@ 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) + * * @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 { createAdminProcessRepository } = require('../../admin-scripts/repositories/admin-process-repository-factory'); + const { createScriptScheduleRepository } = require('../../admin-scripts/repositories/script-schedule-repository-factory'); - const apiKeyRepository = createAdminApiKeyRepository(); - const executionRepository = createScriptExecutionRepository(); + const adminProcessRepository = createAdminProcessRepository(); 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); - } - }, + // ==================== Admin Process Management Commands ==================== /** - * Deactivate an admin API key - * Soft delete - sets isActive to false + * Create a new admin process record * - * @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 {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, @@ -191,59 +69,57 @@ function createAdminScriptCommands() { audit, }) { try { - const execution = await executionRepository.createExecution({ - 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 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.findProcessById(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( - scriptName, - options - ); - return executions; + const processes = await adminProcessRepository.findProcessesByName( + scriptName, + options + ); + return processes; } catch (error) { // Return empty array on error (non-critical) return []; @@ -251,17 +127,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.updateProcessState( + processId, + state ); return updated; } catch (error) { @@ -270,16 +146,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.appendProcessLog( + processId, logEntry ); return updated; @@ -289,46 +165,32 @@ 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 (output !== undefined) { - await executionRepository.updateExecutionOutput( - executionId, - output - ); - } - if (error) { - await executionRepository.updateExecutionError( - executionId, - error - ); + // Update state if provided + if (state) { + await adminProcessRepository.updateProcessState(processId, state); } - if (metrics) { - await executionRepository.updateExecutionMetrics( - executionId, - 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 }; @@ -338,32 +200,29 @@ 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; - - // If status filter provided, use status query - if (status) { - return await executionRepository.findExecutionsByStatus( - status, - { - limit, - sortBy: 'createdAt', - sortOrder: 'desc', - } - ); + const { limit = 20, state, since } = options; + + // If state filter provided, use state query + if (state) { + return await adminProcessRepository.findProcessesByState(state, { + 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 + // For now, fall back to empty array if no state filter return []; } catch (error) { return []; @@ -381,10 +240,7 @@ function createAdminScriptCommands() { */ async getScheduleByScriptName(scriptName) { try { - const schedule = - await scheduleRepository.findScheduleByScriptName( - scriptName - ); + const schedule = await scheduleRepository.findScheduleByScriptName(scriptName); return schedule; } catch (error) { return mapErrorToResponse(error); @@ -401,12 +257,7 @@ function createAdminScriptCommands() { * @param {string} [params.timezone] - Timezone (default 'UTC') * @returns {Promise} Created or updated schedule */ - async upsertSchedule({ - scriptName, - enabled, - cronExpression, - timezone, - }) { + async upsertSchedule({ scriptName, enabled, cronExpression, timezone }) { try { const schedule = await scheduleRepository.upsertSchedule({ scriptName, @@ -428,9 +279,7 @@ function createAdminScriptCommands() { */ async deleteSchedule(scriptName) { try { - const result = await scheduleRepository.deleteSchedule( - scriptName - ); + const result = await scheduleRepository.deleteSchedule(scriptName); return result; } catch (error) { return mapErrorToResponse(error); @@ -438,26 +287,20 @@ 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) { return mapErrorToResponse(error); @@ -474,11 +317,10 @@ function createAdminScriptCommands() { */ async updateScheduleLastTriggered(scriptName, timestamp) { try { - const schedule = - await scheduleRepository.updateScheduleLastTriggered( - scriptName, - timestamp - ); + const schedule = await scheduleRepository.updateScheduleLastTriggered( + scriptName, + timestamp + ); return schedule; } catch (error) { return mapErrorToResponse(error); @@ -494,9 +336,7 @@ function createAdminScriptCommands() { */ async listSchedules(options = {}) { try { - const schedules = await scheduleRepository.listSchedules( - options - ); + const schedules = await scheduleRepository.listSchedules(options); return schedules; } catch (error) { return []; 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/database/use-cases/check-database-state-use-case.js b/packages/core/database/use-cases/check-database-state-use-case.js index 75d290cc0..4d70c312f 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 @@ -64,13 +64,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 dbbb7004d..0985f4cda 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 @@ -62,8 +62,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)', }); }); @@ -81,7 +80,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 418a71310..f18b2d8d1 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 @@ -65,8 +65,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'); @@ -76,8 +75,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 cf129f5b0..4d176b064 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 7e305d36d..fd7b9be83 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 @@ -106,7 +106,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/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/handlers/routers/db-migration.handler.js b/packages/core/handlers/routers/db-migration.handler.js index af926b452..a8b4b124e 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 09978a49f..d3fee4ccb 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'); @@ -64,29 +67,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) * @@ -107,7 +92,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; @@ -145,7 +130,7 @@ router.post( ); /** - * GET /db-migrate/status + * GET /admin/db-migrate/status * * Check if database has pending migrations * @@ -163,7 +148,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'; @@ -191,7 +176,7 @@ router.get( ); /** - * GET /db-migrate/:migrationId + * GET /admin/db-migrate/:migrationId * * Get migration status by migration ID * @@ -215,7 +200,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'; @@ -252,7 +237,7 @@ router.get( ); /** - * POST /db-migrate/resolve + * POST /admin/db-migrate/resolve * * Resolve a failed migration by marking it as applied or rolled back * @@ -272,7 +257,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 20a8b899a..9e9cdadc7 100644 --- a/packages/core/handlers/routers/db-migration.test.js +++ b/packages/core/handlers/routers/db-migration.test.js @@ -50,7 +50,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 @@ -61,7 +61,7 @@ describe('Database Migration Router - Adapter Layer', () => { })); const statusRoute = routes.find( - (r) => r.path === '/db-migrate/status' + (r) => r.path === '/admin/db-migrate/status' ); expect(statusRoute).toBeDefined(); expect(statusRoute.methods).toContain('get'); 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, diff --git a/packages/core/prisma-mongodb/schema.prisma b/packages/core/prisma-mongodb/schema.prisma index 1b68556e2..0645334ae 100644 --- a/packages/core/prisma-mongodb/schema.prisma +++ b/packages/core/prisma-mongodb/schema.prisma @@ -80,26 +80,6 @@ model Token { @@map("Token") } -/// Multi-step authorization session tracking -/// Supports OTP flows and multi-stage authentication (e.g., Nagaris) -model AuthorizationSession { - id String @id @default(auto()) @map("_id") @db.ObjectId - sessionId String @unique - userId String - entityType String - currentStep Int @default(1) - maxSteps Int - stepData Json @default("{}") - expiresAt DateTime - completed Boolean @default(false) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - @@index([userId, entityType]) - @@index([expiresAt]) - @@map("AuthorizationSession") -} - // ============================================================================ // CREDENTIAL & ENTITY MODELS // ============================================================================ @@ -138,7 +118,6 @@ model Entity { name String? moduleName String? externalId String? - isGlobal Boolean @default(false) data Json @default("{}") @@ -159,8 +138,6 @@ model Entity { @@index([externalId]) @@index([moduleName]) @@index([credentialId]) - @@index([isGlobal]) - @@index([isGlobal, moduleName]) @@map("Entity") } @@ -385,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) @@ -461,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 d0b1e4fb5..e544ec23b 100644 --- a/packages/core/prisma-postgresql/schema.prisma +++ b/packages/core/prisma-postgresql/schema.prisma @@ -78,26 +78,6 @@ model Token { @@index([expires]) } -/// Multi-step authorization session tracking -/// Supports OTP flows and multi-stage authentication (e.g., Nagaris) -model AuthorizationSession { - id Int @id @default(autoincrement()) - sessionId String @unique - userId String - entityType String - currentStep Int @default(1) - maxSteps Int - stepData Json @default("{}") - expiresAt DateTime - completed Boolean @default(false) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - @@index([sessionId]) - @@index([userId, entityType]) - @@index([expiresAt]) -} - // ============================================================================ // CREDENTIAL & ENTITY MODELS // ============================================================================ @@ -135,7 +115,6 @@ model Entity { name String? moduleName String? externalId String? - isGlobal Boolean @default(false) data Json @default("{}") @@ -153,8 +132,6 @@ model Entity { @@index([externalId]) @@index([moduleName]) @@index([credentialId]) - @@index([isGlobal]) - @@index([isGlobal, moduleName]) } // ============================================================================ @@ -368,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) @@ -442,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 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)