@modelriver/cli 1.2.3 → 1.2.4
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 +65 -2
- package/package.json +4 -2
- package/src/commands/callback.js +81 -0
- package/src/commands/listen.js +59 -36
- package/src/commands/trigger.js +12 -0
- package/src/commands/websocket.js +59 -5
- package/src/index.js +24 -5
- package/src/lib/api-client.js +22 -0
- package/src/lib/websocket-client.js +3 -3
- package/src/utils/webhook-payload.js +160 -0
- package/src/lib/api-client.test.js +0 -101
- package/src/lib/config.test.js +0 -98
- package/src/lib/webhook-verifier.test.js +0 -90
- package/src/utils/formatter.test.js +0 -84
- package/src/utils/url-helpers.test.js +0 -144
package/README.md
CHANGED
|
@@ -54,6 +54,7 @@ For convenience, the CLI supports short aliases:
|
|
|
54
54
|
| `forward` | `f` | Forward using saved config |
|
|
55
55
|
| `trigger` | `t` | Send async request |
|
|
56
56
|
| `websocket` | `ws` | Test WebSocket connection |
|
|
57
|
+
| `callback` | - | Complete event-driven callback |
|
|
57
58
|
| `test-webhook` | - | Test webhook delivery end-to-end |
|
|
58
59
|
|
|
59
60
|
Example:
|
|
@@ -183,6 +184,38 @@ modelriver listen --port 3002 --forward --print
|
|
|
183
184
|
Data: {"result": "..."}
|
|
184
185
|
```
|
|
185
186
|
|
|
187
|
+
**Event-driven example:**
|
|
188
|
+
```
|
|
189
|
+
[timestamp] Webhook received via WebSocket:
|
|
190
|
+
Channel ID: abc-123-def
|
|
191
|
+
Status: ai_generated (callback required)
|
|
192
|
+
Type: task.ai_generated
|
|
193
|
+
Event: schema_result_ready
|
|
194
|
+
Callback URL: https://api.modelriver.com/v1/callback/abc-123-def
|
|
195
|
+
Customer data: { "ticket_id": "TCK-10042" }
|
|
196
|
+
AI response: { ... }
|
|
197
|
+
|
|
198
|
+
> Event-driven webhook — complete the flow with a callback:
|
|
199
|
+
> modelriver callback --channel-id abc-123-def --data '{"summary":"..."}'
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
### `modelriver callback` - Complete Event-driven Flows
|
|
203
|
+
|
|
204
|
+
After `listen` receives a `task.ai_generated` webhook, complete the flow:
|
|
205
|
+
|
|
206
|
+
```bash
|
|
207
|
+
# Using channel ID
|
|
208
|
+
modelriver callback \
|
|
209
|
+
--channel-id abc-123-def \
|
|
210
|
+
--data '{"summary":"Escalated to auth","priority":"high"}' \
|
|
211
|
+
--task-id TCK-10042
|
|
212
|
+
|
|
213
|
+
# Using full callback URL from the webhook
|
|
214
|
+
modelriver callback \
|
|
215
|
+
--callback-url "https://api.modelriver.com/v1/callback/abc-123-def" \
|
|
216
|
+
--data '{"summary":"Escalated to auth"}'
|
|
217
|
+
```
|
|
218
|
+
|
|
186
219
|
### `modelriver websocket` - Test WebSocket Connection
|
|
187
220
|
|
|
188
221
|
Test WebSocket connections to production and receive real-time responses.
|
|
@@ -231,8 +264,8 @@ Send a test async request and get channel details.
|
|
|
231
264
|
# Basic trigger
|
|
232
265
|
modelriver trigger --workflow my-workflow --message "Test message"
|
|
233
266
|
|
|
234
|
-
# With custom payload
|
|
235
|
-
modelriver trigger --workflow my-workflow --payload '{"messages": [...]}'
|
|
267
|
+
# With custom payload (workflow can be in --payload or via --workflow)
|
|
268
|
+
modelriver trigger --workflow my-workflow --payload '{"messages": [...], "metadata": {"ticket_id":"TCK-1"}}'
|
|
236
269
|
|
|
237
270
|
# Create webhook to receive response
|
|
238
271
|
modelriver trigger --workflow my-workflow --message "Test" --webhook-url https://webhook.site/your-id
|
|
@@ -241,6 +274,17 @@ modelriver trigger --workflow my-workflow --message "Test" --webhook-url https:/
|
|
|
241
274
|
modelriver trigger --workflow my-workflow --message "Test" --print-channel
|
|
242
275
|
```
|
|
243
276
|
|
|
277
|
+
For **event-driven** workflows, after triggering:
|
|
278
|
+
|
|
279
|
+
```bash
|
|
280
|
+
# Terminal 1
|
|
281
|
+
modelriver listen --print
|
|
282
|
+
|
|
283
|
+
# Terminal 2
|
|
284
|
+
modelriver trigger --workflow my-event-workflow --message "Test" --print-channel
|
|
285
|
+
modelriver callback --channel-id CHANNEL_ID --data '{"enriched":true}'
|
|
286
|
+
```
|
|
287
|
+
|
|
244
288
|
**Example Output:**
|
|
245
289
|
```
|
|
246
290
|
✓ Async request created
|
|
@@ -344,6 +388,23 @@ export MODELRIVER_API_KEY=mr_live_YOUR_KEY
|
|
|
344
388
|
modelriver trigger --workflow my-workflow --message "Test" --print-channel
|
|
345
389
|
```
|
|
346
390
|
|
|
391
|
+
### Example 4: Event-driven Workflow End-to-End
|
|
392
|
+
|
|
393
|
+
```bash
|
|
394
|
+
# Terminal 1: Listen for webhooks
|
|
395
|
+
export MODELRIVER_API_KEY=mr_live_YOUR_KEY
|
|
396
|
+
modelriver listen --print
|
|
397
|
+
|
|
398
|
+
# Terminal 2: Trigger + callback
|
|
399
|
+
modelriver trigger \
|
|
400
|
+
--workflow my-event-workflow \
|
|
401
|
+
--payload '{"messages":[{"role":"user","content":"Test"}],"metadata":{"ticket_id":"TCK-1"}}' \
|
|
402
|
+
--print-channel
|
|
403
|
+
|
|
404
|
+
# After Terminal 1 shows task.ai_generated:
|
|
405
|
+
modelriver callback --channel-id CHANNEL_ID --data '{"summary":"Done"}'
|
|
406
|
+
```
|
|
407
|
+
|
|
347
408
|
## Webhook Payload Format
|
|
348
409
|
|
|
349
410
|
Webhooks are sent as HTTP POST requests with the following format:
|
|
@@ -410,6 +471,7 @@ The CLI uses these ModelRiver API endpoints:
|
|
|
410
471
|
- `POST /v1/webhooks` - Create a new webhook
|
|
411
472
|
- `DELETE /v1/webhooks/:id` - Delete a webhook
|
|
412
473
|
- `POST /v1/cli/connect` - Connect CLI and get WebSocket token
|
|
474
|
+
- `POST /v1/callback/:channel_id` - Complete event-driven callback
|
|
413
475
|
|
|
414
476
|
**Development** (localhost or custom API URL):
|
|
415
477
|
- Same endpoints but with `/api/v1` prefix instead of `/v1`
|
|
@@ -429,6 +491,7 @@ Tests cover critical components including:
|
|
|
429
491
|
- Configuration loading
|
|
430
492
|
- API client validation
|
|
431
493
|
- Output formatting
|
|
494
|
+
- Event-driven webhook payload helpers
|
|
432
495
|
|
|
433
496
|
|
|
434
497
|
## License
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@modelriver/cli",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.4",
|
|
4
4
|
"description": "ModelRiver CLI for testing webhooks and WebSockets from production",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -47,6 +47,8 @@
|
|
|
47
47
|
"files": [
|
|
48
48
|
"bin/",
|
|
49
49
|
"src/",
|
|
50
|
-
"README.md"
|
|
50
|
+
"README.md",
|
|
51
|
+
"!src/**/*.test.js",
|
|
52
|
+
"!**/*.test.js"
|
|
51
53
|
]
|
|
52
54
|
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
const ApiClient = require('../lib/api-client');
|
|
2
|
+
const Logger = require('../utils/logger');
|
|
3
|
+
const Formatter = require('../utils/formatter');
|
|
4
|
+
|
|
5
|
+
async function callbackCommand(options) {
|
|
6
|
+
const {
|
|
7
|
+
channelId,
|
|
8
|
+
callbackUrl,
|
|
9
|
+
data,
|
|
10
|
+
taskId,
|
|
11
|
+
metadata,
|
|
12
|
+
payload,
|
|
13
|
+
apiKey,
|
|
14
|
+
apiUrl,
|
|
15
|
+
} = options;
|
|
16
|
+
|
|
17
|
+
if (!callbackUrl && !channelId) {
|
|
18
|
+
Logger.error('Error: --channel-id or --callback-url is required');
|
|
19
|
+
process.exit(1);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
let body;
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
if (payload) {
|
|
26
|
+
body = JSON.parse(payload);
|
|
27
|
+
} else {
|
|
28
|
+
body = {};
|
|
29
|
+
|
|
30
|
+
if (data) {
|
|
31
|
+
body.data = typeof data === 'string' ? JSON.parse(data) : data;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (taskId) {
|
|
35
|
+
body.task_id = taskId;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (metadata) {
|
|
39
|
+
body.metadata =
|
|
40
|
+
typeof metadata === 'string' ? JSON.parse(metadata) : metadata;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
} catch (error) {
|
|
44
|
+
Logger.error(`Invalid JSON: ${error.message}`);
|
|
45
|
+
process.exit(1);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (body.data !== undefined && (typeof body.data !== 'object' || Array.isArray(body.data))) {
|
|
49
|
+
Logger.error('Error: "data" must be a JSON object');
|
|
50
|
+
process.exit(1);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
const apiClient = new ApiClient(apiKey, apiUrl);
|
|
55
|
+
const targetUrl =
|
|
56
|
+
callbackUrl ||
|
|
57
|
+
`${apiClient.apiUrl}${apiClient.apiPath}/callback/${channelId}`;
|
|
58
|
+
|
|
59
|
+
const spinner = Logger.spinner(`Sending callback to ${targetUrl}...`);
|
|
60
|
+
spinner.start();
|
|
61
|
+
|
|
62
|
+
const response = await apiClient.sendCallback(targetUrl, body);
|
|
63
|
+
|
|
64
|
+
if (response.status >= 400) {
|
|
65
|
+
spinner.fail('Callback failed');
|
|
66
|
+
Logger.error(response.data?.error || response.data?.message || `HTTP ${response.status}`);
|
|
67
|
+
if (response.data) {
|
|
68
|
+
console.log(Formatter.json(response.data));
|
|
69
|
+
}
|
|
70
|
+
process.exit(1);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
spinner.succeed('Callback accepted');
|
|
74
|
+
console.log(Formatter.json(response.data));
|
|
75
|
+
} catch (error) {
|
|
76
|
+
Logger.error(error.message);
|
|
77
|
+
process.exit(1);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
module.exports = { callbackCommand };
|
package/src/commands/listen.js
CHANGED
|
@@ -5,6 +5,10 @@ const WebhookVerifier = require('../lib/webhook-verifier');
|
|
|
5
5
|
const Logger = require('../utils/logger');
|
|
6
6
|
const Formatter = require('../utils/formatter');
|
|
7
7
|
const { parseForwardUrl } = require('../utils/url-helpers');
|
|
8
|
+
const {
|
|
9
|
+
printWebhookDetails,
|
|
10
|
+
resolveCallbackUrl,
|
|
11
|
+
} = require('../utils/webhook-payload');
|
|
8
12
|
|
|
9
13
|
async function listenCommand(options) {
|
|
10
14
|
let { apiKey, apiUrl, print, port, verbose, forward, forwardUrl } = options;
|
|
@@ -68,7 +72,7 @@ async function listenCommand(options) {
|
|
|
68
72
|
|
|
69
73
|
// Debug: Show API key info if verbose
|
|
70
74
|
if (verbose) {
|
|
71
|
-
Logger.info(`> API URL: ${apiUrl}`);
|
|
75
|
+
Logger.info(`> API URL: ${apiClient.apiUrl}`);
|
|
72
76
|
Logger.info(`> API Path: ${apiClient.apiPath}`);
|
|
73
77
|
Logger.info(`> API Key (first 30 chars): ${apiKey.substring(0, 30)}...`);
|
|
74
78
|
}
|
|
@@ -152,20 +156,27 @@ async function listenCommand(options) {
|
|
|
152
156
|
);
|
|
153
157
|
}
|
|
154
158
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
159
|
+
// Skip printing when this request came from our own WebSocket forwarder
|
|
160
|
+
// (already printed in the WebSocket handler). Still print external POSTs.
|
|
161
|
+
const isInternalForward = req.headers['x-modelriver-cli-internal'] === '1';
|
|
162
|
+
if (print && !isInternalForward) {
|
|
163
|
+
printWebhookDetails(
|
|
164
|
+
{
|
|
165
|
+
channel_id,
|
|
166
|
+
data,
|
|
167
|
+
callback_url: req.body.callback_url || resolveCallbackUrl(req.body),
|
|
168
|
+
},
|
|
169
|
+
{
|
|
170
|
+
Logger,
|
|
171
|
+
Formatter,
|
|
172
|
+
WebhookVerifier,
|
|
173
|
+
webhookSecret,
|
|
174
|
+
signature,
|
|
175
|
+
timestamp: payloadTimestamp || timestamp,
|
|
176
|
+
apiKey,
|
|
177
|
+
label: 'Webhook received (local server)',
|
|
178
|
+
}
|
|
179
|
+
);
|
|
169
180
|
}
|
|
170
181
|
|
|
171
182
|
// Always return 200 to acknowledge receipt
|
|
@@ -175,6 +186,25 @@ async function listenCommand(options) {
|
|
|
175
186
|
server = app.listen(localPort, 'localhost', () => {
|
|
176
187
|
Logger.success(`> Local server listening on http://localhost:${localPort}/webhook`);
|
|
177
188
|
});
|
|
189
|
+
|
|
190
|
+
server.on('error', (error) => {
|
|
191
|
+
if (error.code === 'EADDRINUSE') {
|
|
192
|
+
spinner.fail(`Port ${localPort} is already in use`);
|
|
193
|
+
Logger.error(`Another process is listening on port ${localPort}.`);
|
|
194
|
+
Logger.warning(`> Use a different port: modelriver listen --print --port ${Number(localPort) + 1}`);
|
|
195
|
+
Logger.warning('> Or stop the other process and try again');
|
|
196
|
+
if (wsClient) {
|
|
197
|
+
wsClient.close();
|
|
198
|
+
}
|
|
199
|
+
process.exit(1);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
Logger.error(`Local server error: ${error.message}`);
|
|
203
|
+
if (wsClient) {
|
|
204
|
+
wsClient.close();
|
|
205
|
+
}
|
|
206
|
+
process.exit(1);
|
|
207
|
+
});
|
|
178
208
|
} else {
|
|
179
209
|
Logger.success(`> Forwarding webhooks to http://localhost:${localPort}/webhook`);
|
|
180
210
|
}
|
|
@@ -184,25 +214,16 @@ async function listenCommand(options) {
|
|
|
184
214
|
const { channel_id, timestamp, data, signature } = webhookPayload;
|
|
185
215
|
|
|
186
216
|
if (print) {
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
);
|
|
198
|
-
Logger.log(` Signature: ${isValid ? '✓ Valid' : '✗ Invalid'}`);
|
|
199
|
-
}
|
|
200
|
-
if (data?.data) {
|
|
201
|
-
Logger.log(` Data: ${Formatter.json(data.data)}`);
|
|
202
|
-
}
|
|
203
|
-
if (data?.meta) {
|
|
204
|
-
Logger.log(` Meta: ${Formatter.json(data.meta)}`);
|
|
205
|
-
}
|
|
217
|
+
printWebhookDetails(webhookPayload, {
|
|
218
|
+
Logger,
|
|
219
|
+
Formatter,
|
|
220
|
+
WebhookVerifier,
|
|
221
|
+
webhookSecret,
|
|
222
|
+
signature,
|
|
223
|
+
timestamp,
|
|
224
|
+
apiKey,
|
|
225
|
+
label: 'Webhook received via WebSocket',
|
|
226
|
+
});
|
|
206
227
|
}
|
|
207
228
|
|
|
208
229
|
// Forward to local server
|
|
@@ -211,7 +232,8 @@ async function listenCommand(options) {
|
|
|
211
232
|
const postData = JSON.stringify({
|
|
212
233
|
channel_id,
|
|
213
234
|
timestamp,
|
|
214
|
-
|
|
235
|
+
callback_url: resolveCallbackUrl(webhookPayload),
|
|
236
|
+
data,
|
|
215
237
|
});
|
|
216
238
|
|
|
217
239
|
// Parse the forward URL or use localhost defaults
|
|
@@ -232,7 +254,8 @@ async function listenCommand(options) {
|
|
|
232
254
|
'Content-Length': Buffer.byteLength(postData),
|
|
233
255
|
'X-ModelRiver-Signature': signature || '',
|
|
234
256
|
'X-ModelRiver-Timestamp': timestamp.toString(),
|
|
235
|
-
'X-ModelRiver-Webhook-Id': webhookPayload.webhook_id || ''
|
|
257
|
+
'X-ModelRiver-Webhook-Id': webhookPayload.webhook_id || '',
|
|
258
|
+
'X-ModelRiver-CLI-Internal': '1',
|
|
236
259
|
}
|
|
237
260
|
};
|
|
238
261
|
|
package/src/commands/trigger.js
CHANGED
|
@@ -23,6 +23,17 @@ async function triggerCommand(options) {
|
|
|
23
23
|
messages: [{ role: 'user', content: message || 'Test from CLI' }]
|
|
24
24
|
};
|
|
25
25
|
|
|
26
|
+
// Allow --workflow with --payload when payload omits workflow
|
|
27
|
+
if (workflow && !requestPayload.workflow) {
|
|
28
|
+
requestPayload.workflow = workflow;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (!requestPayload.workflow) {
|
|
32
|
+
spinner.fail('Workflow required');
|
|
33
|
+
Logger.error('Provide --workflow or include "workflow" in --payload JSON');
|
|
34
|
+
process.exit(1);
|
|
35
|
+
}
|
|
36
|
+
|
|
26
37
|
const response = await apiClient.createAsyncRequest(requestPayload);
|
|
27
38
|
|
|
28
39
|
spinner.succeed('Async request created');
|
|
@@ -61,6 +72,7 @@ async function triggerCommand(options) {
|
|
|
61
72
|
} else {
|
|
62
73
|
Logger.info('\n> Use --webhook-url to automatically receive responses via webhook');
|
|
63
74
|
Logger.info('> Or use "modelriver websocket" to connect and receive responses');
|
|
75
|
+
Logger.info('> For event-driven workflows, complete with: modelriver callback --channel-id <id> --data \'{...}\'');
|
|
64
76
|
}
|
|
65
77
|
|
|
66
78
|
} catch (error) {
|
|
@@ -3,6 +3,11 @@ const WebSocketClient = require('../lib/websocket-client');
|
|
|
3
3
|
const config = require('../lib/config');
|
|
4
4
|
const Logger = require('../utils/logger');
|
|
5
5
|
const Formatter = require('../utils/formatter');
|
|
6
|
+
const {
|
|
7
|
+
buildCallbackHint,
|
|
8
|
+
isFinalWebSocketStatus,
|
|
9
|
+
isIntermediateWebSocketStatus,
|
|
10
|
+
} = require('../utils/webhook-payload');
|
|
6
11
|
|
|
7
12
|
/**
|
|
8
13
|
* Normalize WebSocket URL for production environments
|
|
@@ -74,9 +79,20 @@ async function websocketCommand(options) {
|
|
|
74
79
|
messages: [{ role: 'user', content: message || 'Test from CLI' }]
|
|
75
80
|
};
|
|
76
81
|
|
|
82
|
+
// Allow --workflow with --payload when payload omits workflow
|
|
83
|
+
if (workflow && !requestPayload.workflow) {
|
|
84
|
+
requestPayload.workflow = workflow;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (!requestPayload.workflow) {
|
|
88
|
+
spinner.fail('Workflow required');
|
|
89
|
+
Logger.error('Provide --workflow or include "workflow" in --payload JSON');
|
|
90
|
+
process.exit(1);
|
|
91
|
+
}
|
|
92
|
+
|
|
77
93
|
if (verbose) {
|
|
78
94
|
Logger.info(`\n> API URL: ${apiClient.apiUrl}`);
|
|
79
|
-
Logger.info(`> Workflow: ${workflow}`);
|
|
95
|
+
Logger.info(`> Workflow: ${requestPayload.workflow}`);
|
|
80
96
|
Logger.info(`> Payload: ${JSON.stringify(requestPayload, null, 2)}`);
|
|
81
97
|
}
|
|
82
98
|
|
|
@@ -194,21 +210,59 @@ async function websocketCommand(options) {
|
|
|
194
210
|
|
|
195
211
|
// Set up response listener BEFORE waiting (to catch responses that arrive quickly)
|
|
196
212
|
let responseReceived = false;
|
|
213
|
+
let sawIntermediateEvent = false;
|
|
197
214
|
|
|
198
215
|
// Timeout after 5 minutes
|
|
199
216
|
const timeoutId = setTimeout(() => {
|
|
200
217
|
if (!responseReceived) {
|
|
201
218
|
Logger.error('\n❌ Timeout: No response received after 5 minutes');
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
219
|
+
if (sawIntermediateEvent) {
|
|
220
|
+
Logger.error(' Event-driven flow is waiting for a backend callback.');
|
|
221
|
+
Logger.error(` Complete it with: modelriver callback --channel-id ${channelIdToUse} --data '{"summary":"..."}'`);
|
|
222
|
+
} else {
|
|
223
|
+
Logger.error(' Possible causes:');
|
|
224
|
+
Logger.error(' 1. Workflow is still processing (try waiting longer)');
|
|
225
|
+
Logger.error(' 2. Workflow failed on the server (check server logs)');
|
|
226
|
+
Logger.error(' 3. Server did not broadcast the response');
|
|
227
|
+
}
|
|
206
228
|
wsClient.close();
|
|
207
229
|
process.exit(1);
|
|
208
230
|
}
|
|
209
231
|
}, 5 * 60 * 1000);
|
|
210
232
|
|
|
211
233
|
wsClient.onResponse((payload) => {
|
|
234
|
+
const status = payload?.status;
|
|
235
|
+
|
|
236
|
+
if (isIntermediateWebSocketStatus(status)) {
|
|
237
|
+
sawIntermediateEvent = true;
|
|
238
|
+
Logger.warning(`\n⏳ Event-driven update (${status})`);
|
|
239
|
+
Logger.info(`> Channel ID: ${payload.channel_id || channelIdToUse}`);
|
|
240
|
+
if (payload.event_name) {
|
|
241
|
+
Logger.info(`> Event: ${payload.event_name}`);
|
|
242
|
+
}
|
|
243
|
+
if (payload.message) {
|
|
244
|
+
Logger.info(`> Message: ${payload.message}`);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const callbackUrl = `${apiClient.apiUrl}${apiClient.apiPath}/callback/${channelIdToUse}`;
|
|
248
|
+
const hint = buildCallbackHint(callbackUrl, channelIdToUse, apiClient.apiKey);
|
|
249
|
+
if (hint) {
|
|
250
|
+
Logger.warning(`\n${hint}\n`);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (verbose) {
|
|
254
|
+
Logger.info(`Intermediate payload: ${Formatter.json(payload)}`);
|
|
255
|
+
}
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (!isFinalWebSocketStatus(status) && status) {
|
|
260
|
+
if (verbose) {
|
|
261
|
+
Logger.info(`Received non-final status "${status}", waiting for completion...`);
|
|
262
|
+
}
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
|
|
212
266
|
responseReceived = true;
|
|
213
267
|
clearTimeout(timeoutId);
|
|
214
268
|
Logger.success('\n✅ Response received:\n');
|
package/src/index.js
CHANGED
|
@@ -6,6 +6,7 @@ const { listenCommand } = require('./commands/listen');
|
|
|
6
6
|
const { forwardCommand } = require('./commands/forward');
|
|
7
7
|
const { websocketCommand } = require('./commands/websocket');
|
|
8
8
|
const { triggerCommand } = require('./commands/trigger');
|
|
9
|
+
const { callbackCommand } = require('./commands/callback');
|
|
9
10
|
const { testWebhookCommand } = require('./commands/test-webhook');
|
|
10
11
|
const { listWebhooks, verifyWebhook } = require('./commands/webhook');
|
|
11
12
|
const config = require('./lib/config');
|
|
@@ -48,7 +49,7 @@ program
|
|
|
48
49
|
listenCommand({
|
|
49
50
|
...options,
|
|
50
51
|
apiKey: options.apiKey || program.opts().apiKey,
|
|
51
|
-
apiUrl: options.apiUrl || program.opts().apiUrl
|
|
52
|
+
apiUrl: options.apiUrl || program.opts().apiUrl || config.getApiUrl(),
|
|
52
53
|
});
|
|
53
54
|
});
|
|
54
55
|
|
|
@@ -77,11 +78,29 @@ program
|
|
|
77
78
|
websocketCommand({
|
|
78
79
|
...options,
|
|
79
80
|
apiKey: options.apiKey || program.opts().apiKey,
|
|
80
|
-
apiUrl: options.apiUrl || program.opts().apiUrl,
|
|
81
|
+
apiUrl: options.apiUrl || program.opts().apiUrl || config.getApiUrl(),
|
|
81
82
|
projectId: options.projectId || program.opts().projectId
|
|
82
83
|
});
|
|
83
84
|
});
|
|
84
85
|
|
|
86
|
+
// Callback command - complete event-driven flows
|
|
87
|
+
program
|
|
88
|
+
.command('callback')
|
|
89
|
+
.description('Send an event-driven callback to complete a pending channel')
|
|
90
|
+
.option('-c, --channel-id <id>', 'Channel ID from trigger/listen output')
|
|
91
|
+
.option('--callback-url <url>', 'Full callback URL from webhook payload')
|
|
92
|
+
.option('-d, --data <json>', 'Callback data object JSON')
|
|
93
|
+
.option('-P, --payload <json>', 'Full callback payload JSON')
|
|
94
|
+
.option('--task-id <id>', 'Optional task ID')
|
|
95
|
+
.option('--metadata <json>', 'Optional metadata object JSON')
|
|
96
|
+
.action((options) => {
|
|
97
|
+
callbackCommand({
|
|
98
|
+
...options,
|
|
99
|
+
apiKey: options.apiKey || program.opts().apiKey,
|
|
100
|
+
apiUrl: options.apiUrl || program.opts().apiUrl || config.getApiUrl(),
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
85
104
|
// Test Webhook command
|
|
86
105
|
program
|
|
87
106
|
.command('test-webhook')
|
|
@@ -97,7 +116,7 @@ program
|
|
|
97
116
|
testWebhookCommand({
|
|
98
117
|
...options,
|
|
99
118
|
apiKey: options.apiKey || program.opts().apiKey,
|
|
100
|
-
apiUrl: options.apiUrl || program.opts().apiUrl
|
|
119
|
+
apiUrl: options.apiUrl || program.opts().apiUrl || config.getApiUrl(),
|
|
101
120
|
});
|
|
102
121
|
});
|
|
103
122
|
|
|
@@ -115,7 +134,7 @@ program
|
|
|
115
134
|
triggerCommand({
|
|
116
135
|
...options,
|
|
117
136
|
apiKey: options.apiKey || program.opts().apiKey,
|
|
118
|
-
apiUrl: options.apiUrl || program.opts().apiUrl
|
|
137
|
+
apiUrl: options.apiUrl || program.opts().apiUrl || config.getApiUrl()
|
|
119
138
|
});
|
|
120
139
|
});
|
|
121
140
|
|
|
@@ -132,7 +151,7 @@ webhookCmd
|
|
|
132
151
|
listWebhooks({
|
|
133
152
|
...options,
|
|
134
153
|
apiKey: options.apiKey || program.opts().apiKey,
|
|
135
|
-
apiUrl: options.apiUrl || program.opts().apiUrl
|
|
154
|
+
apiUrl: options.apiUrl || program.opts().apiUrl || config.getApiUrl(),
|
|
136
155
|
});
|
|
137
156
|
});
|
|
138
157
|
|
package/src/lib/api-client.js
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
const axios = require('axios');
|
|
2
2
|
const config = require('./config');
|
|
3
3
|
|
|
4
|
+
function createAuthHeaders(apiKey) {
|
|
5
|
+
return {
|
|
6
|
+
Authorization: `Bearer ${apiKey}`,
|
|
7
|
+
'Content-Type': 'application/json',
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
|
|
4
11
|
class ApiClient {
|
|
5
12
|
constructor(apiKey = null, apiUrl = null) {
|
|
6
13
|
this.apiKey = apiKey || config.getApiKey();
|
|
@@ -166,6 +173,21 @@ class ApiClient {
|
|
|
166
173
|
}
|
|
167
174
|
}
|
|
168
175
|
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Send an event-driven callback to complete a pending channel.
|
|
179
|
+
* @param {string} callbackUrl - Full callback URL from webhook payload
|
|
180
|
+
* @param {object} body - Callback payload { data?, task_id?, metadata? }
|
|
181
|
+
* @returns {Promise<object>} - Axios-like response { status, data }
|
|
182
|
+
*/
|
|
183
|
+
async sendCallback(callbackUrl, body) {
|
|
184
|
+
const response = await axios.post(callbackUrl, body, {
|
|
185
|
+
headers: createAuthHeaders(this.apiKey),
|
|
186
|
+
validateStatus: (status) => status < 500,
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
return response;
|
|
190
|
+
}
|
|
169
191
|
}
|
|
170
192
|
|
|
171
193
|
module.exports = ApiClient;
|
|
@@ -88,7 +88,7 @@ class WebSocketClient {
|
|
|
88
88
|
}
|
|
89
89
|
|
|
90
90
|
const joinRef = '1';
|
|
91
|
-
|
|
91
|
+
|
|
92
92
|
const joinMsg = JSON.stringify({
|
|
93
93
|
topic: channelName,
|
|
94
94
|
event: 'phx_join',
|
|
@@ -110,7 +110,7 @@ class WebSocketClient {
|
|
|
110
110
|
resolved = true;
|
|
111
111
|
// Remove this listener - handleMessage() will continue processing other messages
|
|
112
112
|
this.ws.removeListener('message', handleJoinResponse);
|
|
113
|
-
|
|
113
|
+
|
|
114
114
|
if (msg.payload.status === 'ok') {
|
|
115
115
|
this.channel = channelName;
|
|
116
116
|
resolve(msg.payload);
|
|
@@ -126,7 +126,7 @@ class WebSocketClient {
|
|
|
126
126
|
// Add listener for join response
|
|
127
127
|
// The handleMessage() listener in connect() will also be called, but won't match
|
|
128
128
|
this.ws.on('message', handleJoinResponse);
|
|
129
|
-
|
|
129
|
+
|
|
130
130
|
this.ws.send(joinMsg);
|
|
131
131
|
|
|
132
132
|
// Timeout after 10 seconds
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Helpers for standard vs event-driven webhook payloads.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
function isEventDrivenWebhook(data) {
|
|
6
|
+
return Boolean(data && data.type === 'task.ai_generated');
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function resolveCallbackUrl(webhookPayload) {
|
|
10
|
+
if (!webhookPayload) {
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const { data, callback_url: topLevelCallbackUrl } = webhookPayload;
|
|
15
|
+
|
|
16
|
+
return topLevelCallbackUrl || data?.callback_url || null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function getWebhookStatusLabel(data) {
|
|
20
|
+
if (!data) {
|
|
21
|
+
return 'N/A';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (isEventDrivenWebhook(data)) {
|
|
25
|
+
return 'ai_generated (callback required)';
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return data.status || 'N/A';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function getCustomerData(data) {
|
|
32
|
+
if (!data || typeof data !== 'object') {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const customerData = data.customer_data || data.ai_response?.customer_data;
|
|
37
|
+
if (!customerData || typeof customerData !== 'object') {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return Object.keys(customerData).length > 0 ? customerData : null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function buildCallbackHint(callbackUrl, channelId, apiKey) {
|
|
45
|
+
if (!callbackUrl && !channelId) {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const keyHint = apiKey
|
|
50
|
+
? `${apiKey.substring(0, 12)}...`
|
|
51
|
+
: '$MODELRIVER_API_KEY';
|
|
52
|
+
|
|
53
|
+
const lines = [
|
|
54
|
+
'> Event-driven webhook — complete the flow with a callback:',
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
if (channelId) {
|
|
58
|
+
lines.push(
|
|
59
|
+
`> modelriver callback --channel-id ${channelId} --data '{"summary":"..."}'`
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (callbackUrl) {
|
|
64
|
+
if (channelId) {
|
|
65
|
+
lines.push('> Or:');
|
|
66
|
+
}
|
|
67
|
+
lines.push(
|
|
68
|
+
`> modelriver callback --callback-url "${callbackUrl}" --data '{"summary":"..."}'`
|
|
69
|
+
);
|
|
70
|
+
lines.push(
|
|
71
|
+
`> curl -X POST "${callbackUrl}" \\`,
|
|
72
|
+
`> -H "Authorization: Bearer ${keyHint}" \\`,
|
|
73
|
+
`> -H "Content-Type: application/json" \\`,
|
|
74
|
+
`> -d '{"data":{"enriched":true},"task_id":"your-task-id"}'`
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return lines.join('\n');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function printWebhookDetails(webhookPayload, options = {}) {
|
|
82
|
+
const {
|
|
83
|
+
Logger,
|
|
84
|
+
Formatter,
|
|
85
|
+
WebhookVerifier,
|
|
86
|
+
webhookSecret,
|
|
87
|
+
signature,
|
|
88
|
+
timestamp,
|
|
89
|
+
apiKey,
|
|
90
|
+
label = 'Webhook received',
|
|
91
|
+
} = options;
|
|
92
|
+
|
|
93
|
+
const { channel_id, data } = webhookPayload;
|
|
94
|
+
const callbackUrl = resolveCallbackUrl(webhookPayload);
|
|
95
|
+
const timestampStr = Formatter.timestamp(new Date());
|
|
96
|
+
|
|
97
|
+
Logger.log(`\n[${timestampStr}] ${label}:`);
|
|
98
|
+
Logger.log(` Channel ID: ${channel_id || 'N/A'}`);
|
|
99
|
+
Logger.log(` Status: ${getWebhookStatusLabel(data)}`);
|
|
100
|
+
|
|
101
|
+
if (isEventDrivenWebhook(data)) {
|
|
102
|
+
Logger.log(` Type: ${data.type}`);
|
|
103
|
+
Logger.log(` Event: ${data.event || 'N/A'}`);
|
|
104
|
+
|
|
105
|
+
if (callbackUrl) {
|
|
106
|
+
Logger.log(` Callback URL: ${callbackUrl}`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const customerData = getCustomerData(data);
|
|
110
|
+
if (customerData) {
|
|
111
|
+
Logger.log(` Customer data: ${Formatter.json(customerData)}`);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (data.ai_response) {
|
|
115
|
+
Logger.log(` AI response: ${Formatter.json(data.ai_response)}`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const hint = buildCallbackHint(callbackUrl, channel_id, apiKey);
|
|
119
|
+
if (hint) {
|
|
120
|
+
Logger.warning(`\n${hint}\n`);
|
|
121
|
+
}
|
|
122
|
+
} else {
|
|
123
|
+
if (signature && webhookSecret && data) {
|
|
124
|
+
const isValid = WebhookVerifier.verify(
|
|
125
|
+
signature,
|
|
126
|
+
data,
|
|
127
|
+
webhookSecret,
|
|
128
|
+
String(timestamp)
|
|
129
|
+
);
|
|
130
|
+
Logger.log(` Signature: ${isValid ? '✓ Valid' : '✗ Invalid'}`);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (data?.data) {
|
|
134
|
+
Logger.log(` Data: ${Formatter.json(data.data)}`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (data?.meta) {
|
|
138
|
+
Logger.log(` Meta: ${Formatter.json(data.meta)}`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function isIntermediateWebSocketStatus(status) {
|
|
144
|
+
return status === 'ai_generated' || status === 'step_received' || status === 'pending';
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function isFinalWebSocketStatus(status) {
|
|
148
|
+
return status === 'completed' || status === 'success' || status === 'error';
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
module.exports = {
|
|
152
|
+
isEventDrivenWebhook,
|
|
153
|
+
resolveCallbackUrl,
|
|
154
|
+
getCustomerData,
|
|
155
|
+
getWebhookStatusLabel,
|
|
156
|
+
buildCallbackHint,
|
|
157
|
+
printWebhookDetails,
|
|
158
|
+
isIntermediateWebSocketStatus,
|
|
159
|
+
isFinalWebSocketStatus,
|
|
160
|
+
};
|
|
@@ -1,101 +0,0 @@
|
|
|
1
|
-
// Mock fs to prevent reading from actual ~/.modelriver/config.json
|
|
2
|
-
jest.mock('fs', () => ({
|
|
3
|
-
existsSync: jest.fn(() => false),
|
|
4
|
-
readFileSync: jest.fn(() => '{}'),
|
|
5
|
-
writeFileSync: jest.fn(),
|
|
6
|
-
mkdirSync: jest.fn(),
|
|
7
|
-
}));
|
|
8
|
-
|
|
9
|
-
describe('ApiClient', () => {
|
|
10
|
-
const originalEnv = process.env;
|
|
11
|
-
|
|
12
|
-
beforeEach(() => {
|
|
13
|
-
jest.resetModules();
|
|
14
|
-
// Re-apply the mock after resetModules
|
|
15
|
-
jest.doMock('fs', () => ({
|
|
16
|
-
existsSync: jest.fn(() => false),
|
|
17
|
-
readFileSync: jest.fn(() => '{}'),
|
|
18
|
-
writeFileSync: jest.fn(),
|
|
19
|
-
mkdirSync: jest.fn(),
|
|
20
|
-
}));
|
|
21
|
-
process.env = { ...originalEnv };
|
|
22
|
-
// Clear any config env vars first
|
|
23
|
-
delete process.env.MODELRIVER_API_KEY;
|
|
24
|
-
// Then set a valid API key for tests that need it
|
|
25
|
-
process.env.MODELRIVER_API_KEY = 'mr_live_validtestkey123';
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
afterAll(() => {
|
|
29
|
-
process.env = originalEnv;
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
describe('constructor validation', () => {
|
|
33
|
-
it('should throw error when API key is missing', () => {
|
|
34
|
-
delete process.env.MODELRIVER_API_KEY;
|
|
35
|
-
const ApiClient = require('./api-client');
|
|
36
|
-
|
|
37
|
-
expect(() => new ApiClient(null, 'https://api.modelriver.com'))
|
|
38
|
-
.toThrow('API key is required');
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
it('should throw error for invalid API key format', () => {
|
|
42
|
-
const ApiClient = require('./api-client');
|
|
43
|
-
|
|
44
|
-
expect(() => new ApiClient('invalid-key-format', 'https://api.modelriver.com'))
|
|
45
|
-
.toThrow('Invalid API key format');
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
it('should accept mr_live_ prefixed keys', () => {
|
|
49
|
-
const ApiClient = require('./api-client');
|
|
50
|
-
|
|
51
|
-
expect(() => new ApiClient('mr_live_abc123', 'https://api.modelriver.com'))
|
|
52
|
-
.not.toThrow();
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
it('should accept mr_test_ prefixed keys', () => {
|
|
56
|
-
const ApiClient = require('./api-client');
|
|
57
|
-
|
|
58
|
-
expect(() => new ApiClient('mr_test_abc123', 'https://api.modelriver.com'))
|
|
59
|
-
.not.toThrow();
|
|
60
|
-
});
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
describe('apiPath detection', () => {
|
|
64
|
-
it('should use /v1 for production URLs', () => {
|
|
65
|
-
const ApiClient = require('./api-client');
|
|
66
|
-
const client = new ApiClient('mr_live_test123', 'https://api.modelriver.com');
|
|
67
|
-
|
|
68
|
-
expect(client.apiPath).toBe('/v1');
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
it('should use /v1 for *.modelriver.com subdomains', () => {
|
|
72
|
-
const ApiClient = require('./api-client');
|
|
73
|
-
const client = new ApiClient('mr_live_test123', 'https://staging.modelriver.com');
|
|
74
|
-
|
|
75
|
-
expect(client.apiPath).toBe('/v1');
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
it('should use /api/v1 for localhost', () => {
|
|
79
|
-
const ApiClient = require('./api-client');
|
|
80
|
-
const client = new ApiClient('mr_live_test123', 'http://localhost:4000');
|
|
81
|
-
|
|
82
|
-
expect(client.apiPath).toBe('/api/v1');
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
it('should use /api/v1 for non-production URLs', () => {
|
|
86
|
-
const ApiClient = require('./api-client');
|
|
87
|
-
const client = new ApiClient('mr_live_test123', 'http://my-dev-server.local');
|
|
88
|
-
|
|
89
|
-
expect(client.apiPath).toBe('/api/v1');
|
|
90
|
-
});
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
describe('URL normalization', () => {
|
|
94
|
-
it('should strip trailing slashes from API URL', () => {
|
|
95
|
-
const ApiClient = require('./api-client');
|
|
96
|
-
const client = new ApiClient('mr_live_test123', 'https://api.modelriver.com/');
|
|
97
|
-
|
|
98
|
-
expect(client.apiUrl).toBe('https://api.modelriver.com');
|
|
99
|
-
});
|
|
100
|
-
});
|
|
101
|
-
});
|
package/src/lib/config.test.js
DELETED
|
@@ -1,98 +0,0 @@
|
|
|
1
|
-
// We need to test the Config class, but it's exported as a singleton instance.
|
|
2
|
-
// We'll test the behavior through environment variables.
|
|
3
|
-
|
|
4
|
-
// Mock fs to prevent reading from actual ~/.modelriver/config.json
|
|
5
|
-
jest.mock('fs', () => ({
|
|
6
|
-
existsSync: jest.fn(() => false),
|
|
7
|
-
readFileSync: jest.fn(() => '{}'),
|
|
8
|
-
writeFileSync: jest.fn(),
|
|
9
|
-
mkdirSync: jest.fn(),
|
|
10
|
-
}));
|
|
11
|
-
|
|
12
|
-
describe('Config', () => {
|
|
13
|
-
const originalEnv = process.env;
|
|
14
|
-
|
|
15
|
-
beforeEach(() => {
|
|
16
|
-
// Reset modules to get fresh Config instance
|
|
17
|
-
jest.resetModules();
|
|
18
|
-
// Re-apply the mock after resetModules
|
|
19
|
-
jest.doMock('fs', () => ({
|
|
20
|
-
existsSync: jest.fn(() => false),
|
|
21
|
-
readFileSync: jest.fn(() => '{}'),
|
|
22
|
-
writeFileSync: jest.fn(),
|
|
23
|
-
mkdirSync: jest.fn(),
|
|
24
|
-
}));
|
|
25
|
-
process.env = { ...originalEnv };
|
|
26
|
-
// Clear any config-related env vars
|
|
27
|
-
delete process.env.MODELRIVER_API_KEY;
|
|
28
|
-
delete process.env.MODELRIVER_API_URL;
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
afterAll(() => {
|
|
32
|
-
process.env = originalEnv;
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
describe('getApiKey', () => {
|
|
36
|
-
it('should read API key from MODELRIVER_API_KEY env var', () => {
|
|
37
|
-
process.env.MODELRIVER_API_KEY = 'mr_live_test123';
|
|
38
|
-
const config = require('./config');
|
|
39
|
-
|
|
40
|
-
expect(config.getApiKey()).toBe('mr_live_test123');
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
it('should return undefined when API key not set', () => {
|
|
44
|
-
delete process.env.MODELRIVER_API_KEY;
|
|
45
|
-
const config = require('./config');
|
|
46
|
-
|
|
47
|
-
expect(config.getApiKey()).toBeFalsy();
|
|
48
|
-
});
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
describe('getApiUrl', () => {
|
|
52
|
-
it('should return default URL when not set', () => {
|
|
53
|
-
delete process.env.MODELRIVER_API_URL;
|
|
54
|
-
const config = require('./config');
|
|
55
|
-
|
|
56
|
-
expect(config.getApiUrl()).toBe('https://api.modelriver.com');
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
it('should read API URL from MODELRIVER_API_URL env var', () => {
|
|
60
|
-
process.env.MODELRIVER_API_URL = 'http://localhost:4000';
|
|
61
|
-
const config = require('./config');
|
|
62
|
-
|
|
63
|
-
expect(config.getApiUrl()).toBe('http://localhost:4000');
|
|
64
|
-
});
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
describe('getWebSocketUrl', () => {
|
|
68
|
-
it('should convert https:// to wss://', () => {
|
|
69
|
-
process.env.MODELRIVER_API_URL = 'https://api.modelriver.com';
|
|
70
|
-
const config = require('./config');
|
|
71
|
-
|
|
72
|
-
expect(config.getWebSocketUrl()).toBe('wss://api.modelriver.com');
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
it('should convert http:// to ws://', () => {
|
|
76
|
-
process.env.MODELRIVER_API_URL = 'http://localhost:4000';
|
|
77
|
-
const config = require('./config');
|
|
78
|
-
|
|
79
|
-
expect(config.getWebSocketUrl()).toBe('ws://localhost:4000');
|
|
80
|
-
});
|
|
81
|
-
});
|
|
82
|
-
|
|
83
|
-
describe('get', () => {
|
|
84
|
-
it('should prioritize env vars over defaults', () => {
|
|
85
|
-
process.env.MODELRIVER_CUSTOM_KEY = 'env-value';
|
|
86
|
-
const config = require('./config');
|
|
87
|
-
|
|
88
|
-
expect(config.get('custom-key', 'default-value')).toBe('env-value');
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
it('should return default when env var not set', () => {
|
|
92
|
-
delete process.env.MODELRIVER_MISSING_KEY;
|
|
93
|
-
const config = require('./config');
|
|
94
|
-
|
|
95
|
-
expect(config.get('missing-key', 'fallback')).toBe('fallback');
|
|
96
|
-
});
|
|
97
|
-
});
|
|
98
|
-
});
|
|
@@ -1,90 +0,0 @@
|
|
|
1
|
-
const WebhookVerifier = require('./webhook-verifier');
|
|
2
|
-
|
|
3
|
-
describe('WebhookVerifier', () => {
|
|
4
|
-
const secret = 'test-secret-key-12345';
|
|
5
|
-
const timestamp = '1704825600';
|
|
6
|
-
const payload = { status: 'success', name: 'Test User' };
|
|
7
|
-
|
|
8
|
-
describe('generateSignature', () => {
|
|
9
|
-
it('should generate a consistent HMAC-SHA256 signature', () => {
|
|
10
|
-
const sig1 = WebhookVerifier.generateSignature(payload, secret, timestamp);
|
|
11
|
-
const sig2 = WebhookVerifier.generateSignature(payload, secret, timestamp);
|
|
12
|
-
|
|
13
|
-
expect(sig1).toBe(sig2);
|
|
14
|
-
expect(typeof sig1).toBe('string');
|
|
15
|
-
expect(sig1.length).toBe(64); // SHA256 hex is 64 chars
|
|
16
|
-
});
|
|
17
|
-
|
|
18
|
-
it('should produce different signatures for different payloads', () => {
|
|
19
|
-
const sig1 = WebhookVerifier.generateSignature(payload, secret, timestamp);
|
|
20
|
-
const sig2 = WebhookVerifier.generateSignature({ different: 'data' }, secret, timestamp);
|
|
21
|
-
|
|
22
|
-
expect(sig1).not.toBe(sig2);
|
|
23
|
-
});
|
|
24
|
-
|
|
25
|
-
it('should produce different signatures for different secrets', () => {
|
|
26
|
-
const sig1 = WebhookVerifier.generateSignature(payload, secret, timestamp);
|
|
27
|
-
const sig2 = WebhookVerifier.generateSignature(payload, 'different-secret', timestamp);
|
|
28
|
-
|
|
29
|
-
expect(sig1).not.toBe(sig2);
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
it('should produce different signatures for different timestamps', () => {
|
|
33
|
-
const sig1 = WebhookVerifier.generateSignature(payload, secret, timestamp);
|
|
34
|
-
const sig2 = WebhookVerifier.generateSignature(payload, secret, '1704825601');
|
|
35
|
-
|
|
36
|
-
expect(sig1).not.toBe(sig2);
|
|
37
|
-
});
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
describe('verify', () => {
|
|
41
|
-
it('should return true for valid signatures', () => {
|
|
42
|
-
const signature = WebhookVerifier.generateSignature(payload, secret, timestamp);
|
|
43
|
-
const isValid = WebhookVerifier.verify(signature, payload, secret, timestamp);
|
|
44
|
-
|
|
45
|
-
expect(isValid).toBe(true);
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
it('should return false for tampered payloads', () => {
|
|
49
|
-
const signature = WebhookVerifier.generateSignature(payload, secret, timestamp);
|
|
50
|
-
const tamperedPayload = { ...payload, name: 'Hacker' };
|
|
51
|
-
const isValid = WebhookVerifier.verify(signature, tamperedPayload, secret, timestamp);
|
|
52
|
-
|
|
53
|
-
expect(isValid).toBe(false);
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
it('should return false for wrong secret', () => {
|
|
57
|
-
const signature = WebhookVerifier.generateSignature(payload, secret, timestamp);
|
|
58
|
-
const isValid = WebhookVerifier.verify(signature, payload, 'wrong-secret', timestamp);
|
|
59
|
-
|
|
60
|
-
expect(isValid).toBe(false);
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
it('should return false for wrong timestamp', () => {
|
|
64
|
-
const signature = WebhookVerifier.generateSignature(payload, secret, timestamp);
|
|
65
|
-
const isValid = WebhookVerifier.verify(signature, payload, secret, 'wrong-timestamp');
|
|
66
|
-
|
|
67
|
-
expect(isValid).toBe(false);
|
|
68
|
-
});
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
describe('secureCompare', () => {
|
|
72
|
-
it('should return true for equal strings', () => {
|
|
73
|
-
expect(WebhookVerifier.secureCompare('abc123', 'abc123')).toBe(true);
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
it('should return false for different strings', () => {
|
|
77
|
-
expect(WebhookVerifier.secureCompare('abc123', 'xyz789')).toBe(false);
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
it('should return false for different length strings', () => {
|
|
81
|
-
expect(WebhookVerifier.secureCompare('short', 'muchlongerstring')).toBe(false);
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
it('should return false for non-string inputs', () => {
|
|
85
|
-
expect(WebhookVerifier.secureCompare(null, 'string')).toBe(false);
|
|
86
|
-
expect(WebhookVerifier.secureCompare('string', undefined)).toBe(false);
|
|
87
|
-
expect(WebhookVerifier.secureCompare(123, 456)).toBe(false);
|
|
88
|
-
});
|
|
89
|
-
});
|
|
90
|
-
});
|
|
@@ -1,84 +0,0 @@
|
|
|
1
|
-
const Formatter = require('./formatter');
|
|
2
|
-
|
|
3
|
-
describe('Formatter', () => {
|
|
4
|
-
describe('json', () => {
|
|
5
|
-
it('should format objects with default 2-space indentation', () => {
|
|
6
|
-
const obj = { name: 'test', value: 123 };
|
|
7
|
-
const result = Formatter.json(obj);
|
|
8
|
-
|
|
9
|
-
expect(result).toBe('{\n "name": "test",\n "value": 123\n}');
|
|
10
|
-
});
|
|
11
|
-
|
|
12
|
-
it('should format with custom indentation', () => {
|
|
13
|
-
const obj = { key: 'value' };
|
|
14
|
-
const result = Formatter.json(obj, 4);
|
|
15
|
-
|
|
16
|
-
expect(result).toBe('{\n "key": "value"\n}');
|
|
17
|
-
});
|
|
18
|
-
|
|
19
|
-
it('should handle arrays', () => {
|
|
20
|
-
const arr = [1, 2, 3];
|
|
21
|
-
const result = Formatter.json(arr);
|
|
22
|
-
|
|
23
|
-
expect(result).toBe('[\n 1,\n 2,\n 3\n]');
|
|
24
|
-
});
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
describe('timestamp', () => {
|
|
28
|
-
it('should return ISO format string', () => {
|
|
29
|
-
const date = new Date('2024-01-09T12:00:00Z');
|
|
30
|
-
const result = Formatter.timestamp(date);
|
|
31
|
-
|
|
32
|
-
expect(result).toBe('2024-01-09T12:00:00.000Z');
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
it('should use current date when no argument provided', () => {
|
|
36
|
-
const result = Formatter.timestamp();
|
|
37
|
-
|
|
38
|
-
expect(result).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/);
|
|
39
|
-
});
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
describe('formatDuration', () => {
|
|
43
|
-
it('should format milliseconds', () => {
|
|
44
|
-
expect(Formatter.formatDuration(500)).toBe('500ms');
|
|
45
|
-
expect(Formatter.formatDuration(999)).toBe('999ms');
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
it('should format seconds', () => {
|
|
49
|
-
expect(Formatter.formatDuration(1000)).toBe('1.0s');
|
|
50
|
-
expect(Formatter.formatDuration(5500)).toBe('5.5s');
|
|
51
|
-
expect(Formatter.formatDuration(59999)).toBe('60.0s');
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
it('should format minutes', () => {
|
|
55
|
-
expect(Formatter.formatDuration(60000)).toBe('1.0m');
|
|
56
|
-
expect(Formatter.formatDuration(90000)).toBe('1.5m');
|
|
57
|
-
expect(Formatter.formatDuration(300000)).toBe('5.0m');
|
|
58
|
-
});
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
describe('table', () => {
|
|
62
|
-
it('should format a simple table', () => {
|
|
63
|
-
const headers = ['Name', 'Age'];
|
|
64
|
-
const rows = [
|
|
65
|
-
['Alice', 30],
|
|
66
|
-
['Bob', 25]
|
|
67
|
-
];
|
|
68
|
-
const result = Formatter.table(headers, rows);
|
|
69
|
-
|
|
70
|
-
expect(result).toContain('Name');
|
|
71
|
-
expect(result).toContain('Age');
|
|
72
|
-
expect(result).toContain('Alice');
|
|
73
|
-
expect(result).toContain('Bob');
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
it('should handle empty cells', () => {
|
|
77
|
-
const headers = ['A', 'B'];
|
|
78
|
-
const rows = [['value', null]];
|
|
79
|
-
const result = Formatter.table(headers, rows);
|
|
80
|
-
|
|
81
|
-
expect(result).toContain('value');
|
|
82
|
-
});
|
|
83
|
-
});
|
|
84
|
-
});
|
|
@@ -1,144 +0,0 @@
|
|
|
1
|
-
// Tests for URL helper utilities
|
|
2
|
-
const { normalizeWebhookUrl, extractPort, isValidApiKeyFormat, parseForwardUrl } = require('./url-helpers');
|
|
3
|
-
|
|
4
|
-
describe('URL Helpers', () => {
|
|
5
|
-
describe('normalizeWebhookUrl', () => {
|
|
6
|
-
it('should add /webhook/modelriver to base URL', () => {
|
|
7
|
-
expect(normalizeWebhookUrl('http://localhost:4000'))
|
|
8
|
-
.toBe('http://localhost:4000/webhook/modelriver');
|
|
9
|
-
});
|
|
10
|
-
|
|
11
|
-
it('should add /modelriver to URL ending with /webhook', () => {
|
|
12
|
-
expect(normalizeWebhookUrl('http://localhost:4000/webhook'))
|
|
13
|
-
.toBe('http://localhost:4000/webhook/modelriver');
|
|
14
|
-
});
|
|
15
|
-
|
|
16
|
-
it('should not modify URL already ending with /webhook/modelriver', () => {
|
|
17
|
-
expect(normalizeWebhookUrl('http://localhost:4000/webhook/modelriver'))
|
|
18
|
-
.toBe('http://localhost:4000/webhook/modelriver');
|
|
19
|
-
});
|
|
20
|
-
|
|
21
|
-
it('should handle trailing slashes', () => {
|
|
22
|
-
expect(normalizeWebhookUrl('http://localhost:4000/'))
|
|
23
|
-
.toBe('http://localhost:4000/webhook/modelriver');
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
it('should handle URLs with port', () => {
|
|
27
|
-
expect(normalizeWebhookUrl('http://localhost:3001'))
|
|
28
|
-
.toBe('http://localhost:3001/webhook/modelriver');
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
it('should handle https URLs', () => {
|
|
32
|
-
expect(normalizeWebhookUrl('https://myserver.com'))
|
|
33
|
-
.toBe('https://myserver.com/webhook/modelriver');
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
it('should return empty for falsy input', () => {
|
|
37
|
-
expect(normalizeWebhookUrl('')).toBeFalsy();
|
|
38
|
-
expect(normalizeWebhookUrl(null)).toBeFalsy();
|
|
39
|
-
expect(normalizeWebhookUrl(undefined)).toBeFalsy();
|
|
40
|
-
});
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
describe('extractPort', () => {
|
|
44
|
-
it('should extract port from URL with explicit port', () => {
|
|
45
|
-
expect(extractPort('http://localhost:4000')).toBe(4000);
|
|
46
|
-
expect(extractPort('http://localhost:3001/webhook')).toBe(3001);
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
it('should return 80 for http without port', () => {
|
|
50
|
-
expect(extractPort('http://example.com')).toBe(80);
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
it('should return 443 for https without port', () => {
|
|
54
|
-
expect(extractPort('https://example.com')).toBe(443);
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
it('should return null for invalid URLs', () => {
|
|
58
|
-
expect(extractPort('not-a-url')).toBeNull();
|
|
59
|
-
expect(extractPort('')).toBeNull();
|
|
60
|
-
});
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
describe('isValidApiKeyFormat', () => {
|
|
64
|
-
it('should accept mr_live_ prefixed keys', () => {
|
|
65
|
-
expect(isValidApiKeyFormat('mr_live_abc123xyz')).toBe(true);
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
it('should accept mr_test_ prefixed keys', () => {
|
|
69
|
-
expect(isValidApiKeyFormat('mr_test_abc123xyz')).toBe(true);
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
it('should reject keys without valid prefix', () => {
|
|
73
|
-
expect(isValidApiKeyFormat('invalid_key')).toBe(false);
|
|
74
|
-
expect(isValidApiKeyFormat('mr_invalid_key')).toBe(false);
|
|
75
|
-
expect(isValidApiKeyFormat('abc123')).toBe(false);
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
it('should reject empty or falsy keys', () => {
|
|
79
|
-
expect(isValidApiKeyFormat('')).toBe(false);
|
|
80
|
-
expect(isValidApiKeyFormat(null)).toBe(false);
|
|
81
|
-
expect(isValidApiKeyFormat(undefined)).toBe(false);
|
|
82
|
-
});
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
describe('parseForwardUrl', () => {
|
|
86
|
-
it('should parse localhost URL with port', () => {
|
|
87
|
-
const result = parseForwardUrl('http://localhost:4000/webhook/modelriver');
|
|
88
|
-
expect(result).toEqual({
|
|
89
|
-
hostname: 'localhost',
|
|
90
|
-
port: 4000,
|
|
91
|
-
path: '/webhook/modelriver',
|
|
92
|
-
protocol: 'http'
|
|
93
|
-
});
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
it('should parse external URL with port', () => {
|
|
97
|
-
const result = parseForwardUrl('http://myserver.com:3001/webhook/modelriver');
|
|
98
|
-
expect(result).toEqual({
|
|
99
|
-
hostname: 'myserver.com',
|
|
100
|
-
port: 3001,
|
|
101
|
-
path: '/webhook/modelriver',
|
|
102
|
-
protocol: 'http'
|
|
103
|
-
});
|
|
104
|
-
});
|
|
105
|
-
|
|
106
|
-
it('should default to port 80 for http without explicit port', () => {
|
|
107
|
-
const result = parseForwardUrl('http://example.com/webhook/modelriver');
|
|
108
|
-
expect(result.port).toBe(80);
|
|
109
|
-
expect(result.hostname).toBe('example.com');
|
|
110
|
-
});
|
|
111
|
-
|
|
112
|
-
it('should default to port 443 for https without explicit port', () => {
|
|
113
|
-
const result = parseForwardUrl('https://example.com/webhook/modelriver');
|
|
114
|
-
expect(result.port).toBe(443);
|
|
115
|
-
expect(result.protocol).toBe('https');
|
|
116
|
-
});
|
|
117
|
-
|
|
118
|
-
it('should handle URLs with different paths', () => {
|
|
119
|
-
const result = parseForwardUrl('http://localhost:5000/custom/path');
|
|
120
|
-
expect(result.path).toBe('/custom/path');
|
|
121
|
-
});
|
|
122
|
-
|
|
123
|
-
it('should return null for empty or falsy input', () => {
|
|
124
|
-
expect(parseForwardUrl('')).toBeNull();
|
|
125
|
-
expect(parseForwardUrl(null)).toBeNull();
|
|
126
|
-
expect(parseForwardUrl(undefined)).toBeNull();
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
it('should handle URLs without protocol by defaulting to http', () => {
|
|
130
|
-
const result = parseForwardUrl('myserver:4000/webhook/modelriver');
|
|
131
|
-
expect(result).toEqual({
|
|
132
|
-
hostname: 'myserver',
|
|
133
|
-
port: 4000,
|
|
134
|
-
path: '/webhook/modelriver',
|
|
135
|
-
protocol: 'http'
|
|
136
|
-
});
|
|
137
|
-
});
|
|
138
|
-
|
|
139
|
-
it('should return null for invalid URLs', () => {
|
|
140
|
-
expect(parseForwardUrl('http://invalid url with spaces')).toBeNull();
|
|
141
|
-
expect(parseForwardUrl('just text with spaces')).toBeNull();
|
|
142
|
-
});
|
|
143
|
-
});
|
|
144
|
-
});
|