@friggframework/admin-scripts 2.0.0--canary.517.095e3f6.0 → 2.0.0--canary.517.44c158e.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -9
- package/package.json +6 -6
- package/src/application/__tests__/admin-script-context.test.js +27 -118
- package/src/application/admin-script-context.js +12 -52
- package/src/application/script-runner.js +4 -0
- package/src/infrastructure/__tests__/admin-script-router.test.js +2 -0
- package/src/infrastructure/__tests__/bootstrap.test.js +35 -0
- package/src/infrastructure/__tests__/script-executor-handler.test.js +5 -0
- package/src/infrastructure/admin-script-router.js +22 -23
- package/src/infrastructure/bootstrap.js +46 -3
- package/src/infrastructure/script-executor-handler.js +12 -4
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @friggframework/admin-scripts
|
|
2
2
|
|
|
3
|
-
Admin Script Runner for Frigg — write and run operational/maintenance scripts inside your deployed Frigg app, with VPC/KMS-secured database access
|
|
3
|
+
Admin Script Runner for Frigg — write and run operational/maintenance scripts inside your deployed Frigg app, with VPC/KMS-secured database access through the same Frigg commands your integrations use, sync or async (queued) execution, dry-run validation, and optional cron scheduling via AWS EventBridge Scheduler.
|
|
4
4
|
|
|
5
5
|
Typical use cases:
|
|
6
6
|
|
|
@@ -87,12 +87,17 @@ class AttioHealingScript extends AdminScriptBase {
|
|
|
87
87
|
|
|
88
88
|
this.context.log('info', 'Starting Attio healing', { integrationId });
|
|
89
89
|
|
|
90
|
+
// Read persisted data through Frigg commands (never repositories).
|
|
91
|
+
// Commands return the data on success, or an { error, reason } object
|
|
92
|
+
// on failure — check `.error` yourself.
|
|
90
93
|
const integration =
|
|
91
|
-
await this.context.
|
|
94
|
+
await this.context.commands.integrations.findIntegrationById(
|
|
92
95
|
integrationId
|
|
93
96
|
);
|
|
94
|
-
if (
|
|
95
|
-
throw new Error(
|
|
97
|
+
if (integration.error) {
|
|
98
|
+
throw new Error(
|
|
99
|
+
`Integration ${integrationId} not found: ${integration.reason}`
|
|
100
|
+
);
|
|
96
101
|
}
|
|
97
102
|
|
|
98
103
|
// Call the live integration when you need to hit an external API.
|
|
@@ -117,16 +122,16 @@ module.exports = { AttioHealingScript };
|
|
|
117
122
|
| Member | Description |
|
|
118
123
|
| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
|
|
119
124
|
| `log(level, message, data?)` | Records a structured log entry (`level`: `debug`/`info`/`warn`/`error`). Logs are persisted to the execution's `results.logs`. |
|
|
120
|
-
| `
|
|
121
|
-
| `
|
|
122
|
-
| `
|
|
123
|
-
| `
|
|
125
|
+
| `commands.users` | User commands (`findIndividualUserById`, `createUser`, `updateUser`, …). |
|
|
126
|
+
| `commands.credentials` | Credential commands (`findCredential`, `updateCredential`, …); secrets decrypted transparently. |
|
|
127
|
+
| `commands.entities` | Entity/module commands (`findEntityById`, `findEntitiesByUserId`, …). |
|
|
128
|
+
| `commands.integrations` | Integration reads (`findIntegrationById`, `listIntegrations({ type, status })`). |
|
|
124
129
|
| `instantiate(integrationId)` | Returns a hydrated integration instance for calling external APIs. Requires `config.requireIntegrationInstance: true`. |
|
|
125
130
|
| `queueScript(name, params?)` | Enqueue another script as a follow-up (tracked as a child of the current execution). |
|
|
126
131
|
| `queueScriptBatch(entries)` | Enqueue many follow-up scripts at once. |
|
|
127
132
|
| `getExecutionId()` | The current `AdminProcess` record id. |
|
|
128
133
|
|
|
129
|
-
|
|
134
|
+
Commands return **plain, decrypted data** on success (field-level encryption is handled transparently) or an `{ error, reason, code }` object on failure — scripts check `.error` themselves. Scripts have no direct repository access; all database interaction goes through the command layer.
|
|
130
135
|
|
|
131
136
|
---
|
|
132
137
|
|
package/package.json
CHANGED
|
@@ -1,19 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@friggframework/admin-scripts",
|
|
3
3
|
"prettier": "@friggframework/prettier-config",
|
|
4
|
-
"version": "2.0.0--canary.517.
|
|
4
|
+
"version": "2.0.0--canary.517.44c158e.0",
|
|
5
5
|
"description": "Admin Script Runner for Frigg - Execute maintenance and operational scripts in hosted environments",
|
|
6
6
|
"dependencies": {
|
|
7
7
|
"@aws-sdk/client-scheduler": "^3.588.0",
|
|
8
|
-
"@friggframework/core": "2.0.0--canary.517.
|
|
8
|
+
"@friggframework/core": "2.0.0--canary.517.44c158e.0",
|
|
9
9
|
"@hapi/boom": "^10.0.1",
|
|
10
10
|
"express": "^4.18.2",
|
|
11
11
|
"serverless-http": "^3.2.0"
|
|
12
12
|
},
|
|
13
13
|
"devDependencies": {
|
|
14
|
-
"@friggframework/eslint-config": "2.0.0--canary.517.
|
|
15
|
-
"@friggframework/prettier-config": "2.0.0--canary.517.
|
|
16
|
-
"@friggframework/test": "2.0.0--canary.517.
|
|
14
|
+
"@friggframework/eslint-config": "2.0.0--canary.517.44c158e.0",
|
|
15
|
+
"@friggframework/prettier-config": "2.0.0--canary.517.44c158e.0",
|
|
16
|
+
"@friggframework/test": "2.0.0--canary.517.44c158e.0",
|
|
17
17
|
"eslint": "^8.22.0",
|
|
18
18
|
"jest": "^29.7.0",
|
|
19
19
|
"prettier": "^2.7.1",
|
|
@@ -44,5 +44,5 @@
|
|
|
44
44
|
"maintenance",
|
|
45
45
|
"operations"
|
|
46
46
|
],
|
|
47
|
-
"gitHead": "
|
|
47
|
+
"gitHead": "44c158e7a3bbeaac4b410c1062b0ce209bffd7f0"
|
|
48
48
|
}
|
|
@@ -1,80 +1,24 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
1
3
|
const {
|
|
2
4
|
AdminScriptContext,
|
|
3
5
|
createAdminScriptContext,
|
|
4
6
|
} = require('../admin-script-context');
|
|
5
7
|
|
|
6
|
-
// Mock all repository factories
|
|
7
|
-
jest.mock(
|
|
8
|
-
'@friggframework/core/integrations/repositories/integration-repository-factory'
|
|
9
|
-
);
|
|
10
|
-
jest.mock('@friggframework/core/user/repositories/user-repository-factory');
|
|
11
|
-
jest.mock(
|
|
12
|
-
'@friggframework/core/modules/repositories/module-repository-factory'
|
|
13
|
-
);
|
|
14
|
-
jest.mock(
|
|
15
|
-
'@friggframework/core/credential/repositories/credential-repository-factory'
|
|
16
|
-
);
|
|
17
8
|
jest.mock('@friggframework/core/queues');
|
|
18
9
|
|
|
19
10
|
describe('AdminScriptContext', () => {
|
|
20
|
-
let mockIntegrationRepo;
|
|
21
|
-
let mockUserRepo;
|
|
22
|
-
let mockModuleRepo;
|
|
23
|
-
let mockCredentialRepo;
|
|
24
11
|
let mockQueuerUtil;
|
|
25
12
|
|
|
26
13
|
beforeEach(() => {
|
|
27
14
|
jest.clearAllMocks();
|
|
28
15
|
|
|
29
|
-
mockIntegrationRepo = {
|
|
30
|
-
findIntegrations: jest.fn(),
|
|
31
|
-
findIntegrationById: jest.fn(),
|
|
32
|
-
findIntegrationsByUserId: jest.fn(),
|
|
33
|
-
updateIntegrationConfig: jest.fn(),
|
|
34
|
-
updateIntegrationStatus: jest.fn(),
|
|
35
|
-
};
|
|
36
|
-
|
|
37
|
-
mockUserRepo = {
|
|
38
|
-
findIndividualUserById: jest.fn(),
|
|
39
|
-
findIndividualUserByAppUserId: jest.fn(),
|
|
40
|
-
findIndividualUserByUsername: jest.fn(),
|
|
41
|
-
};
|
|
42
|
-
|
|
43
|
-
mockModuleRepo = {
|
|
44
|
-
findEntity: jest.fn(),
|
|
45
|
-
findEntityById: jest.fn(),
|
|
46
|
-
findEntitiesByUserId: jest.fn(),
|
|
47
|
-
};
|
|
48
|
-
|
|
49
|
-
mockCredentialRepo = {
|
|
50
|
-
findCredential: jest.fn(),
|
|
51
|
-
updateCredential: jest.fn(),
|
|
52
|
-
};
|
|
53
|
-
|
|
54
16
|
mockQueuerUtil = {
|
|
55
17
|
send: jest.fn().mockResolvedValue(undefined),
|
|
56
18
|
batchSend: jest.fn().mockResolvedValue(undefined),
|
|
57
19
|
};
|
|
58
20
|
|
|
59
|
-
const {
|
|
60
|
-
createIntegrationRepository,
|
|
61
|
-
} = require('@friggframework/core/integrations/repositories/integration-repository-factory');
|
|
62
|
-
const {
|
|
63
|
-
createUserRepository,
|
|
64
|
-
} = require('@friggframework/core/user/repositories/user-repository-factory');
|
|
65
|
-
const {
|
|
66
|
-
createModuleRepository,
|
|
67
|
-
} = require('@friggframework/core/modules/repositories/module-repository-factory');
|
|
68
|
-
const {
|
|
69
|
-
createCredentialRepository,
|
|
70
|
-
} = require('@friggframework/core/credential/repositories/credential-repository-factory');
|
|
71
21
|
const { QueuerUtil } = require('@friggframework/core/queues');
|
|
72
|
-
|
|
73
|
-
createIntegrationRepository.mockReturnValue(mockIntegrationRepo);
|
|
74
|
-
createUserRepository.mockReturnValue(mockUserRepo);
|
|
75
|
-
createModuleRepository.mockReturnValue(mockModuleRepo);
|
|
76
|
-
createCredentialRepository.mockReturnValue(mockCredentialRepo);
|
|
77
|
-
|
|
78
22
|
QueuerUtil.send = mockQueuerUtil.send;
|
|
79
23
|
QueuerUtil.batchSend = mockQueuerUtil.batchSend;
|
|
80
24
|
});
|
|
@@ -86,6 +30,19 @@ describe('AdminScriptContext', () => {
|
|
|
86
30
|
expect(ctx.executionId).toBe('exec_123');
|
|
87
31
|
expect(ctx.logs).toEqual([]);
|
|
88
32
|
expect(ctx.integrationFactory).toBeNull();
|
|
33
|
+
expect(ctx.commands).toBeNull();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('exposes the injected command bundle as context.commands', () => {
|
|
37
|
+
const commands = {
|
|
38
|
+
users: {},
|
|
39
|
+
credentials: {},
|
|
40
|
+
entities: {},
|
|
41
|
+
integrations: {},
|
|
42
|
+
};
|
|
43
|
+
const ctx = new AdminScriptContext({ commands });
|
|
44
|
+
|
|
45
|
+
expect(ctx.commands).toBe(commands);
|
|
89
46
|
});
|
|
90
47
|
|
|
91
48
|
it('creates with integrationFactory', () => {
|
|
@@ -106,71 +63,23 @@ describe('AdminScriptContext', () => {
|
|
|
106
63
|
});
|
|
107
64
|
});
|
|
108
65
|
|
|
109
|
-
describe('
|
|
110
|
-
it('
|
|
66
|
+
describe('command surface (no direct repository access)', () => {
|
|
67
|
+
it('does not expose repository getters', () => {
|
|
111
68
|
const ctx = new AdminScriptContext();
|
|
112
|
-
const {
|
|
113
|
-
createIntegrationRepository,
|
|
114
|
-
} = require('@friggframework/core/integrations/repositories/integration-repository-factory');
|
|
115
|
-
|
|
116
|
-
expect(createIntegrationRepository).not.toHaveBeenCalled();
|
|
117
69
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
expect(
|
|
121
|
-
expect(
|
|
70
|
+
expect(ctx.integrationRepository).toBeUndefined();
|
|
71
|
+
expect(ctx.userRepository).toBeUndefined();
|
|
72
|
+
expect(ctx.moduleRepository).toBeUndefined();
|
|
73
|
+
expect(ctx.credentialRepository).toBeUndefined();
|
|
122
74
|
});
|
|
123
75
|
|
|
124
|
-
it('
|
|
125
|
-
const
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
expect(repo1).toBe(repo2);
|
|
131
|
-
expect(repo1).toBe(mockIntegrationRepo);
|
|
132
|
-
});
|
|
133
|
-
|
|
134
|
-
it('creates userRepository on first access', () => {
|
|
135
|
-
const ctx = new AdminScriptContext();
|
|
136
|
-
const {
|
|
137
|
-
createUserRepository,
|
|
138
|
-
} = require('@friggframework/core/user/repositories/user-repository-factory');
|
|
139
|
-
|
|
140
|
-
expect(createUserRepository).not.toHaveBeenCalled();
|
|
141
|
-
|
|
142
|
-
const repo = ctx.userRepository;
|
|
143
|
-
|
|
144
|
-
expect(createUserRepository).toHaveBeenCalledTimes(1);
|
|
145
|
-
expect(repo).toBe(mockUserRepo);
|
|
146
|
-
});
|
|
147
|
-
|
|
148
|
-
it('creates moduleRepository on first access', () => {
|
|
149
|
-
const ctx = new AdminScriptContext();
|
|
150
|
-
const {
|
|
151
|
-
createModuleRepository,
|
|
152
|
-
} = require('@friggframework/core/modules/repositories/module-repository-factory');
|
|
153
|
-
|
|
154
|
-
expect(createModuleRepository).not.toHaveBeenCalled();
|
|
155
|
-
|
|
156
|
-
const repo = ctx.moduleRepository;
|
|
157
|
-
|
|
158
|
-
expect(createModuleRepository).toHaveBeenCalledTimes(1);
|
|
159
|
-
expect(repo).toBe(mockModuleRepo);
|
|
160
|
-
});
|
|
161
|
-
|
|
162
|
-
it('creates credentialRepository on first access', () => {
|
|
163
|
-
const ctx = new AdminScriptContext();
|
|
164
|
-
const {
|
|
165
|
-
createCredentialRepository,
|
|
166
|
-
} = require('@friggframework/core/credential/repositories/credential-repository-factory');
|
|
167
|
-
|
|
168
|
-
expect(createCredentialRepository).not.toHaveBeenCalled();
|
|
169
|
-
|
|
170
|
-
const repo = ctx.credentialRepository;
|
|
76
|
+
it('the context module never imports a repository factory', () => {
|
|
77
|
+
const source = fs.readFileSync(
|
|
78
|
+
path.join(__dirname, '..', 'admin-script-context.js'),
|
|
79
|
+
'utf8'
|
|
80
|
+
);
|
|
171
81
|
|
|
172
|
-
expect(
|
|
173
|
-
expect(repo).toBe(mockCredentialRepo);
|
|
82
|
+
expect(source).not.toMatch(/repository-factory/);
|
|
174
83
|
});
|
|
175
84
|
});
|
|
176
85
|
|
|
@@ -3,23 +3,27 @@ const { QueuerUtil } = require('@friggframework/core/queues');
|
|
|
3
3
|
/**
|
|
4
4
|
* AdminScriptContext - Execution environment for admin scripts
|
|
5
5
|
*
|
|
6
|
-
* Provides a controlled surface area for scripts to interact with
|
|
7
|
-
*
|
|
6
|
+
* Provides a controlled surface area for scripts to interact with the Frigg
|
|
7
|
+
* platform. Scripts touch the database only through injected Frigg commands
|
|
8
|
+
* (`context.commands`) — never through repositories directly. Capabilities:
|
|
8
9
|
*
|
|
10
|
+
* - **Frigg commands**: `commands.users`, `commands.credentials`,
|
|
11
|
+
* `commands.entities`, and `commands.integrations` expose the framework's
|
|
12
|
+
* command layer. Each command returns data on success or an `{ error }`
|
|
13
|
+
* object on failure — scripts check `.error` themselves.
|
|
9
14
|
* - **Admin bypass**: `instantiate()` passes `_isAdminContext: true` to
|
|
10
15
|
* skip user-ownership checks when loading integration instances
|
|
11
16
|
* - **Script chaining**: `queueScript()` / `queueScriptBatch()` let scripts
|
|
12
17
|
* enqueue follow-up work with parent execution tracking
|
|
13
18
|
* - **Execution-scoped logging**: `log()` collects structured entries tied
|
|
14
19
|
* to the current execution for post-run inspection
|
|
15
|
-
* - **Lazy-loaded repositories**: Repos are exposed directly as getters
|
|
16
|
-
* so scripts can query any data they need without wrapper indirection
|
|
17
20
|
*/
|
|
18
21
|
class AdminScriptContext {
|
|
19
22
|
/**
|
|
20
23
|
* @param {Object} [params={}] - Context configuration
|
|
21
24
|
* @param {string|number|null} [params.executionId] - ID of the AdminProcess record this context is scoped to (used for log persistence and script chaining)
|
|
22
25
|
* @param {Object|null} [params.integrationFactory] - Factory used to hydrate integration instances; required for scripts that call instantiate()
|
|
26
|
+
* @param {Object|null} [params.commands] - Frigg command bundle ({ users, credentials, entities, integrations }) injected by the composition root and exposed to scripts as context.commands
|
|
23
27
|
*/
|
|
24
28
|
constructor(params = {}) {
|
|
25
29
|
this.executionId = params.executionId || null;
|
|
@@ -27,54 +31,10 @@ class AdminScriptContext {
|
|
|
27
31
|
|
|
28
32
|
this.integrationFactory = params.integrationFactory || null;
|
|
29
33
|
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
|
|
33
|
-
this.
|
|
34
|
-
this._moduleRepository = null;
|
|
35
|
-
this._credentialRepository = null;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
// ==================== LAZY-LOADED REPOSITORIES ====================
|
|
39
|
-
|
|
40
|
-
get integrationRepository() {
|
|
41
|
-
if (!this._integrationRepository) {
|
|
42
|
-
const {
|
|
43
|
-
createIntegrationRepository,
|
|
44
|
-
} = require('@friggframework/core/integrations/repositories/integration-repository-factory');
|
|
45
|
-
this._integrationRepository = createIntegrationRepository();
|
|
46
|
-
}
|
|
47
|
-
return this._integrationRepository;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
get userRepository() {
|
|
51
|
-
if (!this._userRepository) {
|
|
52
|
-
const {
|
|
53
|
-
createUserRepository,
|
|
54
|
-
} = require('@friggframework/core/user/repositories/user-repository-factory');
|
|
55
|
-
this._userRepository = createUserRepository();
|
|
56
|
-
}
|
|
57
|
-
return this._userRepository;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
get moduleRepository() {
|
|
61
|
-
if (!this._moduleRepository) {
|
|
62
|
-
const {
|
|
63
|
-
createModuleRepository,
|
|
64
|
-
} = require('@friggframework/core/modules/repositories/module-repository-factory');
|
|
65
|
-
this._moduleRepository = createModuleRepository();
|
|
66
|
-
}
|
|
67
|
-
return this._moduleRepository;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
get credentialRepository() {
|
|
71
|
-
if (!this._credentialRepository) {
|
|
72
|
-
const {
|
|
73
|
-
createCredentialRepository,
|
|
74
|
-
} = require('@friggframework/core/credential/repositories/credential-repository-factory');
|
|
75
|
-
this._credentialRepository = createCredentialRepository();
|
|
76
|
-
}
|
|
77
|
-
return this._credentialRepository;
|
|
34
|
+
// Scripts interact with the database only through these Frigg commands.
|
|
35
|
+
// Injected by bootstrap.js — the context never reaches for repositories
|
|
36
|
+
// or command factories itself.
|
|
37
|
+
this.commands = params.commands || null;
|
|
78
38
|
}
|
|
79
39
|
|
|
80
40
|
// ==================== INTEGRATION INSTANTIATION ====================
|
|
@@ -21,6 +21,8 @@ class ScriptRunner {
|
|
|
21
21
|
* to a fresh createAdminScriptCommands().
|
|
22
22
|
* @param {Object} [params.integrationFactory] - Hydrates integration
|
|
23
23
|
* instances for scripts that need them.
|
|
24
|
+
* @param {Object} [params.scriptCommands] - Frigg command bundle exposed to
|
|
25
|
+
* scripts as context.commands (built by bootstrap.js).
|
|
24
26
|
*/
|
|
25
27
|
constructor(params = {}) {
|
|
26
28
|
if (!params.scriptFactory) {
|
|
@@ -29,6 +31,7 @@ class ScriptRunner {
|
|
|
29
31
|
this.scriptFactory = params.scriptFactory;
|
|
30
32
|
this.commands = params.commands || createAdminScriptCommands();
|
|
31
33
|
this.integrationFactory = params.integrationFactory || null;
|
|
34
|
+
this.scriptCommands = params.scriptCommands || null;
|
|
32
35
|
}
|
|
33
36
|
|
|
34
37
|
/**
|
|
@@ -100,6 +103,7 @@ class ScriptRunner {
|
|
|
100
103
|
const context = createAdminScriptContext({
|
|
101
104
|
executionId,
|
|
102
105
|
integrationFactory: this.integrationFactory,
|
|
106
|
+
commands: this.scriptCommands,
|
|
103
107
|
});
|
|
104
108
|
|
|
105
109
|
let output;
|
|
@@ -72,6 +72,7 @@ describe('Admin Script Router', () => {
|
|
|
72
72
|
bootstrapAdminScripts.mockReturnValue({
|
|
73
73
|
scriptFactory: mockFactory,
|
|
74
74
|
integrationFactory: {},
|
|
75
|
+
scriptCommands: {},
|
|
75
76
|
});
|
|
76
77
|
createScriptRunner.mockReturnValue(mockRunner);
|
|
77
78
|
createAdminScriptCommands.mockReturnValue(mockCommands);
|
|
@@ -177,6 +178,7 @@ describe('Admin Script Router', () => {
|
|
|
177
178
|
expect(createScriptRunner).toHaveBeenCalledWith({
|
|
178
179
|
scriptFactory: mockFactory,
|
|
179
180
|
integrationFactory: {},
|
|
181
|
+
scriptCommands: {},
|
|
180
182
|
});
|
|
181
183
|
});
|
|
182
184
|
|
|
@@ -3,6 +3,27 @@ const { AdminScriptBase } = require('../../application/admin-script-base');
|
|
|
3
3
|
|
|
4
4
|
jest.mock('@friggframework/core/handlers/app-definition-loader');
|
|
5
5
|
|
|
6
|
+
// The command bundle is built from these factories; mock them so bootstrap
|
|
7
|
+
// doesn't need a configured database to construct the command groups.
|
|
8
|
+
jest.mock('@friggframework/core/application/commands/user-commands', () => ({
|
|
9
|
+
createUserCommands: jest.fn(() => ({ __kind: 'users' })),
|
|
10
|
+
}));
|
|
11
|
+
jest.mock(
|
|
12
|
+
'@friggframework/core/application/commands/credential-commands',
|
|
13
|
+
() => ({
|
|
14
|
+
createCredentialCommands: jest.fn(() => ({ __kind: 'credentials' })),
|
|
15
|
+
})
|
|
16
|
+
);
|
|
17
|
+
jest.mock('@friggframework/core/application/commands/entity-commands', () => ({
|
|
18
|
+
createEntityCommands: jest.fn(() => ({ __kind: 'entities' })),
|
|
19
|
+
}));
|
|
20
|
+
jest.mock(
|
|
21
|
+
'@friggframework/core/application/commands/integration-commands',
|
|
22
|
+
() => ({
|
|
23
|
+
createIntegrationCommands: jest.fn(() => ({ __kind: 'integrations' })),
|
|
24
|
+
})
|
|
25
|
+
);
|
|
26
|
+
|
|
6
27
|
const {
|
|
7
28
|
loadAppDefinition,
|
|
8
29
|
} = require('@friggframework/core/handlers/app-definition-loader');
|
|
@@ -64,9 +85,23 @@ describe('bootstrapAdminScripts', () => {
|
|
|
64
85
|
|
|
65
86
|
expect(second.scriptFactory).toBe(first.scriptFactory);
|
|
66
87
|
expect(second.integrationFactory).toBe(first.integrationFactory);
|
|
88
|
+
expect(second.scriptCommands).toBe(first.scriptCommands);
|
|
67
89
|
expect(loadAppDefinition).toHaveBeenCalledTimes(1);
|
|
68
90
|
});
|
|
69
91
|
|
|
92
|
+
it('builds the injectable command bundle (users, credentials, entities, integrations)', () => {
|
|
93
|
+
loadAppDefinition.mockReturnValue({ adminScripts: [] });
|
|
94
|
+
|
|
95
|
+
const { scriptCommands } = bootstrapAdminScripts();
|
|
96
|
+
|
|
97
|
+
expect(scriptCommands).toEqual({
|
|
98
|
+
users: { __kind: 'users' },
|
|
99
|
+
credentials: { __kind: 'credentials' },
|
|
100
|
+
entities: { __kind: 'entities' },
|
|
101
|
+
integrations: { __kind: 'integrations' },
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
70
105
|
it('tolerates an app definition with no adminScripts', () => {
|
|
71
106
|
loadAppDefinition.mockReturnValue({});
|
|
72
107
|
|
|
@@ -12,12 +12,14 @@ const { handler } = require('../script-executor-handler');
|
|
|
12
12
|
describe('Admin Script Executor Handler', () => {
|
|
13
13
|
let mockScriptFactory;
|
|
14
14
|
let mockIntegrationFactory;
|
|
15
|
+
let mockScriptCommands;
|
|
15
16
|
let mockRunner;
|
|
16
17
|
let mockCommands;
|
|
17
18
|
|
|
18
19
|
beforeEach(() => {
|
|
19
20
|
mockScriptFactory = { id: 'script-factory' };
|
|
20
21
|
mockIntegrationFactory = { id: 'integration-factory' };
|
|
22
|
+
mockScriptCommands = { id: 'script-commands' };
|
|
21
23
|
mockRunner = { execute: jest.fn() };
|
|
22
24
|
mockCommands = {
|
|
23
25
|
completeAdminProcess: jest.fn().mockResolvedValue({}),
|
|
@@ -26,6 +28,7 @@ describe('Admin Script Executor Handler', () => {
|
|
|
26
28
|
bootstrapAdminScripts.mockReturnValue({
|
|
27
29
|
scriptFactory: mockScriptFactory,
|
|
28
30
|
integrationFactory: mockIntegrationFactory,
|
|
31
|
+
scriptCommands: mockScriptCommands,
|
|
29
32
|
});
|
|
30
33
|
createScriptRunner.mockReturnValue(mockRunner);
|
|
31
34
|
createAdminScriptCommands.mockReturnValue(mockCommands);
|
|
@@ -56,6 +59,7 @@ describe('Admin Script Executor Handler', () => {
|
|
|
56
59
|
expect(createScriptRunner).toHaveBeenCalledWith({
|
|
57
60
|
scriptFactory: mockScriptFactory,
|
|
58
61
|
integrationFactory: mockIntegrationFactory,
|
|
62
|
+
scriptCommands: mockScriptCommands,
|
|
59
63
|
});
|
|
60
64
|
expect(mockRunner.execute).toHaveBeenCalledWith(
|
|
61
65
|
'my-script',
|
|
@@ -114,6 +118,7 @@ describe('Admin Script Executor Handler', () => {
|
|
|
114
118
|
expect(createScriptRunner).toHaveBeenCalledWith({
|
|
115
119
|
scriptFactory: mockScriptFactory,
|
|
116
120
|
integrationFactory: mockIntegrationFactory,
|
|
121
|
+
scriptCommands: mockScriptCommands,
|
|
117
122
|
});
|
|
118
123
|
expect(mockRunner.execute).toHaveBeenCalledWith(
|
|
119
124
|
'my-script',
|
|
@@ -23,19 +23,12 @@ const {
|
|
|
23
23
|
|
|
24
24
|
const router = express.Router();
|
|
25
25
|
|
|
26
|
-
// Apply auth middleware to all admin routes
|
|
26
|
+
// Apply auth middleware to all admin routes. Each handler then calls
|
|
27
|
+
// bootstrapAdminScripts() (memoized, so it runs once per process) to obtain the
|
|
28
|
+
// scriptFactory / integrationFactory / scriptCommands it needs — no global, and
|
|
29
|
+
// endpoints that don't touch scripts skip the work entirely.
|
|
27
30
|
router.use(validateAdminApiKey);
|
|
28
31
|
|
|
29
|
-
// Build (once, memoized) and inject the per-process dependencies onto the
|
|
30
|
-
// request, so route handlers consume them explicitly instead of reaching for a
|
|
31
|
-
// global. Registers the host app's admin scripts on the first request.
|
|
32
|
-
router.use((req, _res, next) => {
|
|
33
|
-
const { scriptFactory, integrationFactory } = bootstrapAdminScripts();
|
|
34
|
-
req.scriptFactory = scriptFactory;
|
|
35
|
-
req.integrationFactory = integrationFactory;
|
|
36
|
-
next();
|
|
37
|
-
});
|
|
38
|
-
|
|
39
32
|
/**
|
|
40
33
|
* Translate a thrown error into an HTTP response. Boom errors (thrown by the
|
|
41
34
|
* schedule use cases) carry their own status code; anything else is an
|
|
@@ -116,9 +109,9 @@ function createScheduleUseCases(scriptFactory) {
|
|
|
116
109
|
* GET /admin/scripts
|
|
117
110
|
* List all registered scripts
|
|
118
111
|
*/
|
|
119
|
-
router.get('/scripts', async (
|
|
112
|
+
router.get('/scripts', async (_req, res) => {
|
|
120
113
|
try {
|
|
121
|
-
const factory =
|
|
114
|
+
const { scriptFactory: factory } = bootstrapAdminScripts();
|
|
122
115
|
const scripts = factory.getAll();
|
|
123
116
|
|
|
124
117
|
res.json({
|
|
@@ -144,7 +137,7 @@ router.get('/scripts', async (req, res) => {
|
|
|
144
137
|
router.get('/scripts/:scriptName', async (req, res) => {
|
|
145
138
|
try {
|
|
146
139
|
const { scriptName } = req.params;
|
|
147
|
-
const factory =
|
|
140
|
+
const { scriptFactory: factory } = bootstrapAdminScripts();
|
|
148
141
|
|
|
149
142
|
if (!factory.has(scriptName)) {
|
|
150
143
|
return res.status(404).json({
|
|
@@ -179,7 +172,7 @@ router.post('/scripts/:scriptName/validate', async (req, res) => {
|
|
|
179
172
|
try {
|
|
180
173
|
const { scriptName } = req.params;
|
|
181
174
|
const { params = {} } = req.body;
|
|
182
|
-
const factory =
|
|
175
|
+
const { scriptFactory: factory } = bootstrapAdminScripts();
|
|
183
176
|
|
|
184
177
|
if (!factory.has(scriptName)) {
|
|
185
178
|
return res.status(404).json({
|
|
@@ -204,7 +197,11 @@ router.post('/scripts/:scriptName', async (req, res) => {
|
|
|
204
197
|
try {
|
|
205
198
|
const { scriptName } = req.params;
|
|
206
199
|
const { params = {}, mode = 'async' } = req.body;
|
|
207
|
-
const
|
|
200
|
+
const {
|
|
201
|
+
scriptFactory: factory,
|
|
202
|
+
integrationFactory,
|
|
203
|
+
scriptCommands,
|
|
204
|
+
} = bootstrapAdminScripts();
|
|
208
205
|
|
|
209
206
|
if (!factory.has(scriptName)) {
|
|
210
207
|
return res.status(404).json({
|
|
@@ -242,8 +239,9 @@ router.post('/scripts/:scriptName', async (req, res) => {
|
|
|
242
239
|
}
|
|
243
240
|
|
|
244
241
|
const runner = createScriptRunner({
|
|
245
|
-
scriptFactory:
|
|
246
|
-
integrationFactory
|
|
242
|
+
scriptFactory: factory,
|
|
243
|
+
integrationFactory,
|
|
244
|
+
scriptCommands,
|
|
247
245
|
});
|
|
248
246
|
const result = await runner.execute(scriptName, params, {
|
|
249
247
|
trigger: 'MANUAL',
|
|
@@ -361,9 +359,8 @@ router.get('/scripts/:scriptName/executions', async (req, res) => {
|
|
|
361
359
|
router.get('/scripts/:scriptName/schedule', async (req, res) => {
|
|
362
360
|
try {
|
|
363
361
|
const { scriptName } = req.params;
|
|
364
|
-
const {
|
|
365
|
-
|
|
366
|
-
);
|
|
362
|
+
const { scriptFactory } = bootstrapAdminScripts();
|
|
363
|
+
const { getEffectiveSchedule } = createScheduleUseCases(scriptFactory);
|
|
367
364
|
|
|
368
365
|
const result = await getEffectiveSchedule.execute(scriptName);
|
|
369
366
|
|
|
@@ -385,7 +382,8 @@ router.put('/scripts/:scriptName/schedule', async (req, res) => {
|
|
|
385
382
|
try {
|
|
386
383
|
const { scriptName } = req.params;
|
|
387
384
|
const { enabled, cronExpression, timezone } = req.body;
|
|
388
|
-
const {
|
|
385
|
+
const { scriptFactory } = bootstrapAdminScripts();
|
|
386
|
+
const { upsertSchedule } = createScheduleUseCases(scriptFactory);
|
|
389
387
|
|
|
390
388
|
const result = await upsertSchedule.execute(scriptName, {
|
|
391
389
|
enabled,
|
|
@@ -415,7 +413,8 @@ router.put('/scripts/:scriptName/schedule', async (req, res) => {
|
|
|
415
413
|
router.delete('/scripts/:scriptName/schedule', async (req, res) => {
|
|
416
414
|
try {
|
|
417
415
|
const { scriptName } = req.params;
|
|
418
|
-
const {
|
|
416
|
+
const { scriptFactory } = bootstrapAdminScripts();
|
|
417
|
+
const { deleteSchedule } = createScheduleUseCases(scriptFactory);
|
|
419
418
|
|
|
420
419
|
const result = await deleteSchedule.execute(scriptName);
|
|
421
420
|
|
|
@@ -15,6 +15,7 @@ const { ScriptFactory } = require('../application/script-factory');
|
|
|
15
15
|
*/
|
|
16
16
|
let bootstrapped = false;
|
|
17
17
|
let scriptFactory = null;
|
|
18
|
+
let scriptCommands = null;
|
|
18
19
|
let integrationFactory = null;
|
|
19
20
|
|
|
20
21
|
function registerScripts(factory, scriptClasses) {
|
|
@@ -45,11 +46,41 @@ function createIntegrationFactory() {
|
|
|
45
46
|
}
|
|
46
47
|
|
|
47
48
|
/**
|
|
48
|
-
*
|
|
49
|
+
* Build the command bundle injected into every AdminScriptContext. Scripts
|
|
50
|
+
* interact with the database only through these Frigg commands — never through
|
|
51
|
+
* repositories directly. The require is lazy so the package keeps no load-time
|
|
52
|
+
* coupling to core's command/repository modules.
|
|
53
|
+
*/
|
|
54
|
+
function buildScriptCommands() {
|
|
55
|
+
const {
|
|
56
|
+
createUserCommands,
|
|
57
|
+
} = require('@friggframework/core/application/commands/user-commands');
|
|
58
|
+
const {
|
|
59
|
+
createCredentialCommands,
|
|
60
|
+
} = require('@friggframework/core/application/commands/credential-commands');
|
|
61
|
+
const {
|
|
62
|
+
createEntityCommands,
|
|
63
|
+
} = require('@friggframework/core/application/commands/entity-commands');
|
|
64
|
+
const {
|
|
65
|
+
createIntegrationCommands,
|
|
66
|
+
} = require('@friggframework/core/application/commands/integration-commands');
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
users: createUserCommands(),
|
|
70
|
+
credentials: createCredentialCommands(),
|
|
71
|
+
entities: createEntityCommands(),
|
|
72
|
+
// Class-agnostic reads (findIntegrationById, listIntegrations). Scripts
|
|
73
|
+
// that need class-scoped integration ops build their own commands.
|
|
74
|
+
integrations: createIntegrationCommands(),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* @returns {{ scriptFactory: ScriptFactory, scriptCommands: object, integrationFactory: object }}
|
|
49
80
|
*/
|
|
50
81
|
function bootstrapAdminScripts() {
|
|
51
82
|
if (bootstrapped) {
|
|
52
|
-
return { scriptFactory, integrationFactory };
|
|
83
|
+
return { scriptFactory, scriptCommands, integrationFactory };
|
|
53
84
|
}
|
|
54
85
|
bootstrapped = true;
|
|
55
86
|
|
|
@@ -72,14 +103,26 @@ function bootstrapAdminScripts() {
|
|
|
72
103
|
);
|
|
73
104
|
}
|
|
74
105
|
|
|
106
|
+
// Built in its own try so a command-layer failure never blocks script
|
|
107
|
+
// registration (and vice versa) — both honor the never-throw contract.
|
|
108
|
+
try {
|
|
109
|
+
scriptCommands = buildScriptCommands();
|
|
110
|
+
} catch (error) {
|
|
111
|
+
console.error(
|
|
112
|
+
'[admin-scripts] bootstrap: could not build command bundle:',
|
|
113
|
+
error.message
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
75
117
|
integrationFactory = createIntegrationFactory();
|
|
76
|
-
return { scriptFactory, integrationFactory };
|
|
118
|
+
return { scriptFactory, scriptCommands, integrationFactory };
|
|
77
119
|
}
|
|
78
120
|
|
|
79
121
|
/** Test-only: reset memoized bootstrap state. */
|
|
80
122
|
function _resetBootstrapForTests() {
|
|
81
123
|
bootstrapped = false;
|
|
82
124
|
scriptFactory = null;
|
|
125
|
+
scriptCommands = null;
|
|
83
126
|
integrationFactory = null;
|
|
84
127
|
}
|
|
85
128
|
|
|
@@ -16,10 +16,14 @@ const { bootstrapAdminScripts } = require('./bootstrap');
|
|
|
16
16
|
* @param {Object} deps
|
|
17
17
|
* @param {ScriptFactory} deps.scriptFactory - Registry used to resolve and instantiate the script.
|
|
18
18
|
* @param {Object} deps.integrationFactory - Hydrates integration instances for scripts that need them.
|
|
19
|
+
* @param {Object} deps.scriptCommands - Frigg command bundle exposed to the script as context.commands.
|
|
19
20
|
* @returns {Promise<{ scriptName: string, status: string, executionId: string }>}
|
|
20
21
|
* @private
|
|
21
22
|
*/
|
|
22
|
-
async function runMessage(
|
|
23
|
+
async function runMessage(
|
|
24
|
+
message,
|
|
25
|
+
{ scriptFactory, integrationFactory, scriptCommands }
|
|
26
|
+
) {
|
|
23
27
|
const { scriptName, executionId, trigger, params, parentExecutionId } =
|
|
24
28
|
message;
|
|
25
29
|
|
|
@@ -33,7 +37,11 @@ async function runMessage(message, { scriptFactory, integrationFactory }) {
|
|
|
33
37
|
}`
|
|
34
38
|
);
|
|
35
39
|
|
|
36
|
-
const runner = createScriptRunner({
|
|
40
|
+
const runner = createScriptRunner({
|
|
41
|
+
scriptFactory,
|
|
42
|
+
integrationFactory,
|
|
43
|
+
scriptCommands,
|
|
44
|
+
});
|
|
37
45
|
const result = await runner.execute(scriptName, params, {
|
|
38
46
|
trigger: trigger || 'QUEUE',
|
|
39
47
|
mode: 'async',
|
|
@@ -80,7 +88,7 @@ async function markFailed(executionId, error) {
|
|
|
80
88
|
* Handle an EventBridge Scheduler direct invoke: the event itself is a single
|
|
81
89
|
* execution message (no `Records` wrapper).
|
|
82
90
|
* @param {Object} event - The execution message.
|
|
83
|
-
* @param {{ scriptFactory: ScriptFactory, integrationFactory: object }} deps
|
|
91
|
+
* @param {{ scriptFactory: ScriptFactory, integrationFactory: object, scriptCommands: object }} deps
|
|
84
92
|
* @returns {Promise<{ statusCode: number, body: string }>}
|
|
85
93
|
* @private
|
|
86
94
|
*/
|
|
@@ -118,7 +126,7 @@ async function handleScheduledInvoke(event, deps) {
|
|
|
118
126
|
* (manual async executions and queueScript continuations). Failures are isolated
|
|
119
127
|
* per record so one bad message doesn't drop the rest of the batch.
|
|
120
128
|
* @param {Object} event - The SQS event with a `Records` array.
|
|
121
|
-
* @param {{ scriptFactory: ScriptFactory, integrationFactory: object }} deps
|
|
129
|
+
* @param {{ scriptFactory: ScriptFactory, integrationFactory: object, scriptCommands: object }} deps
|
|
122
130
|
* @returns {Promise<{ statusCode: number, body: string }>}
|
|
123
131
|
* @private
|
|
124
132
|
*/
|