@modelriver/cli 1.2.3 → 1.2.5

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 CHANGED
@@ -30,9 +30,10 @@ modelriver login
30
30
 
31
31
  You'll be prompted for:
32
32
  1. **API Key**: Your ModelRiver API key (starts with `mr_live_` or `mr_test_`)
33
- 2. **Forward URL**: Where to forward webhooks (e.g., `http://localhost:4000`)
33
+ 2. **Forward URL**: Where to forward webhooks on your machine (e.g., `http://localhost:3001/webhook/modelriver`)
34
34
 
35
- The URL will be automatically normalized to include `/webhook/modelriver`.
35
+ This is your local webhook receiver (CLI default port **3001**), not the ModelRiver API port (**4000**).
36
+ The URL will be automatically normalized to include `/webhook/modelriver` if needed.
36
37
 
37
38
  ### Step 2: Start Forwarding
38
39
 
@@ -54,6 +55,7 @@ For convenience, the CLI supports short aliases:
54
55
  | `forward` | `f` | Forward using saved config |
55
56
  | `trigger` | `t` | Send async request |
56
57
  | `websocket` | `ws` | Test WebSocket connection |
58
+ | `callback` | - | Complete event-driven callback |
57
59
  | `test-webhook` | - | Test webhook delivery end-to-end |
58
60
 
59
61
  Example:
@@ -90,7 +92,7 @@ Create a config file at `~/.modelriver/config.json` or `.modelriverrc` in your p
90
92
  {
91
93
  "api_key": "mr_live_YOUR_API_KEY",
92
94
  "api_url": "https://api.modelriver.com",
93
- "forward_url": "http://localhost:4000/webhook/modelriver"
95
+ "forward_url": "http://localhost:3001/webhook/modelriver"
94
96
  }
95
97
  ```
96
98
 
@@ -113,14 +115,14 @@ ModelRiver CLI Login
113
115
  Configure your ModelRiver CLI credentials.
114
116
 
115
117
  Enter your ModelRiver API key: mr_live_abc123...
116
- Enter your webhook forward URL (e.g. http://localhost:4000/webhook/modelriver): http://localhost:4000
118
+ Enter your webhook forward URL (e.g. http://localhost:3001/webhook/modelriver): http://localhost:3001/webhook/modelriver
117
119
 
118
120
  ✓ Configuration saved!
119
121
 
120
122
  Saved Configuration
121
123
  ───────────────────
122
124
  ✓ API Key: mr_live_abc123...
123
- ✓ Forward URL: http://localhost:4000/webhook/modelriver
125
+ ✓ Forward URL: http://localhost:3001/webhook/modelriver
124
126
  ```
125
127
 
126
128
  ### `modelriver forward` - Quick Webhook Forwarding
@@ -183,6 +185,38 @@ modelriver listen --port 3002 --forward --print
183
185
  Data: {"result": "..."}
184
186
  ```
185
187
 
188
+ **Event-driven example:**
189
+ ```
190
+ [timestamp] Webhook received via WebSocket:
191
+ Channel ID: abc-123-def
192
+ Status: ai_generated (callback required)
193
+ Type: task.ai_generated
194
+ Event: schema_result_ready
195
+ Callback URL: https://api.modelriver.com/v1/callback/abc-123-def
196
+ Customer data: { "ticket_id": "TCK-10042" }
197
+ AI response: { ... }
198
+
199
+ > Event-driven webhook — complete the flow with a callback:
200
+ > modelriver callback --channel-id abc-123-def --data '{"summary":"..."}'
201
+ ```
202
+
203
+ ### `modelriver callback` - Complete Event-driven Flows
204
+
205
+ After `listen` receives a `task.ai_generated` webhook, complete the flow:
206
+
207
+ ```bash
208
+ # Using channel ID
209
+ modelriver callback \
210
+ --channel-id abc-123-def \
211
+ --data '{"summary":"Escalated to auth","priority":"high"}' \
212
+ --task-id TCK-10042
213
+
214
+ # Using full callback URL from the webhook
215
+ modelriver callback \
216
+ --callback-url "https://api.modelriver.com/v1/callback/abc-123-def" \
217
+ --data '{"summary":"Escalated to auth"}'
218
+ ```
219
+
186
220
  ### `modelriver websocket` - Test WebSocket Connection
187
221
 
188
222
  Test WebSocket connections to production and receive real-time responses.
@@ -231,8 +265,8 @@ Send a test async request and get channel details.
231
265
  # Basic trigger
232
266
  modelriver trigger --workflow my-workflow --message "Test message"
233
267
 
234
- # With custom payload
235
- modelriver trigger --workflow my-workflow --payload '{"messages": [...]}'
268
+ # With custom payload (workflow can be in --payload or via --workflow)
269
+ modelriver trigger --workflow my-workflow --payload '{"messages": [...], "metadata": {"ticket_id":"TCK-1"}}'
236
270
 
237
271
  # Create webhook to receive response
238
272
  modelriver trigger --workflow my-workflow --message "Test" --webhook-url https://webhook.site/your-id
@@ -241,6 +275,17 @@ modelriver trigger --workflow my-workflow --message "Test" --webhook-url https:/
241
275
  modelriver trigger --workflow my-workflow --message "Test" --print-channel
242
276
  ```
243
277
 
278
+ For **event-driven** workflows, after triggering:
279
+
280
+ ```bash
281
+ # Terminal 1
282
+ modelriver listen --print
283
+
284
+ # Terminal 2
285
+ modelriver trigger --workflow my-event-workflow --message "Test" --print-channel
286
+ modelriver callback --channel-id CHANNEL_ID --data '{"enriched":true}'
287
+ ```
288
+
244
289
  **Example Output:**
245
290
  ```
246
291
  ✓ Async request created
@@ -344,6 +389,23 @@ export MODELRIVER_API_KEY=mr_live_YOUR_KEY
344
389
  modelriver trigger --workflow my-workflow --message "Test" --print-channel
345
390
  ```
346
391
 
392
+ ### Example 4: Event-driven Workflow End-to-End
393
+
394
+ ```bash
395
+ # Terminal 1: Listen for webhooks
396
+ export MODELRIVER_API_KEY=mr_live_YOUR_KEY
397
+ modelriver listen --print
398
+
399
+ # Terminal 2: Trigger + callback
400
+ modelriver trigger \
401
+ --workflow my-event-workflow \
402
+ --payload '{"messages":[{"role":"user","content":"Test"}],"metadata":{"ticket_id":"TCK-1"}}' \
403
+ --print-channel
404
+
405
+ # After Terminal 1 shows task.ai_generated:
406
+ modelriver callback --channel-id CHANNEL_ID --data '{"summary":"Done"}'
407
+ ```
408
+
347
409
  ## Webhook Payload Format
348
410
 
349
411
  Webhooks are sent as HTTP POST requests with the following format:
@@ -410,6 +472,7 @@ The CLI uses these ModelRiver API endpoints:
410
472
  - `POST /v1/webhooks` - Create a new webhook
411
473
  - `DELETE /v1/webhooks/:id` - Delete a webhook
412
474
  - `POST /v1/cli/connect` - Connect CLI and get WebSocket token
475
+ - `POST /v1/callback/:channel_id` - Complete event-driven callback
413
476
 
414
477
  **Development** (localhost or custom API URL):
415
478
  - Same endpoints but with `/api/v1` prefix instead of `/v1`
@@ -429,6 +492,7 @@ Tests cover critical components including:
429
492
  - Configuration loading
430
493
  - API client validation
431
494
  - Output formatting
495
+ - Event-driven webhook payload helpers
432
496
 
433
497
 
434
498
  ## License
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@modelriver/cli",
3
- "version": "1.2.3",
3
+ "version": "1.2.5",
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 };
@@ -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
- if (print) {
156
- const timestampStr = Formatter.timestamp(new Date());
157
- Logger.log(`\n[${timestampStr}] Webhook received:`);
158
- Logger.log(` Channel ID: ${channel_id || 'N/A'}`);
159
- Logger.log(` Status: ${data?.status || 'N/A'}`);
160
- if (isValid !== null) {
161
- Logger.log(` Signature: ${isValid ? '✓ Valid' : '✗ Invalid'}`);
162
- }
163
- if (data?.data) {
164
- Logger.log(` Data: ${Formatter.json(data.data)}`);
165
- }
166
- if (data?.meta) {
167
- Logger.log(` Meta: ${Formatter.json(data.meta)}`);
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
- const timestampStr = Formatter.timestamp(new Date());
188
- Logger.log(`\n[${timestampStr}] Webhook received via WebSocket:`);
189
- Logger.log(` Channel ID: ${channel_id || 'N/A'}`);
190
- Logger.log(` Status: ${data?.status || 'N/A'}`);
191
- if (signature && webhookSecret) {
192
- const isValid = WebhookVerifier.verify(
193
- signature,
194
- data,
195
- webhookSecret,
196
- timestamp.toString()
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
- data
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
 
@@ -114,10 +114,11 @@ async function loginCommand(options) {
114
114
  }
115
115
 
116
116
  // Prompt for forward URL
117
- const defaultForwardUrl = currentForwardUrl || 'http://localhost:4000';
117
+ // Default to the CLI local receiver port (3001), not ModelRiver API (4000).
118
+ const defaultForwardUrl = currentForwardUrl || 'http://localhost:3001/webhook/modelriver';
118
119
  let forwardUrl = await prompt(
119
120
  rl,
120
- 'Enter your webhook forward URL (e.g. http://localhost:4000/webhook/modelriver)',
121
+ 'Enter your webhook forward URL (e.g. http://localhost:3001/webhook/modelriver)',
121
122
  defaultForwardUrl,
122
123
  false, // maskDefault
123
124
  false // showDefault - Hide the (http://...) bracket as requested
@@ -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
- Logger.error(' Possible causes:');
203
- Logger.error(' 1. Workflow is still processing (try waiting longer)');
204
- Logger.error(' 2. Workflow failed on the server (check server logs)');
205
- Logger.error(' 3. Server did not broadcast the response');
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
 
@@ -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
- });
@@ -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
- });