@modelriver/cli 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +406 -0
- package/bin/modelriver +5 -0
- package/package.json +52 -0
- package/src/commands/forward.js +61 -0
- package/src/commands/listen.js +315 -0
- package/src/commands/login.js +176 -0
- package/src/commands/test-webhook.js +289 -0
- package/src/commands/trigger.js +77 -0
- package/src/commands/webhook.js +101 -0
- package/src/commands/websocket.js +249 -0
- package/src/index.js +154 -0
- package/src/lib/api-client.js +173 -0
- package/src/lib/api-client.test.js +101 -0
- package/src/lib/cli-websocket-client.js +249 -0
- package/src/lib/config.js +137 -0
- package/src/lib/config.test.js +98 -0
- package/src/lib/webhook-verifier.js +56 -0
- package/src/lib/webhook-verifier.test.js +90 -0
- package/src/lib/websocket-client.js +225 -0
- package/src/utils/formatter.js +43 -0
- package/src/utils/formatter.test.js +84 -0
- package/src/utils/logger.js +39 -0
- package/src/utils/url-helpers.js +98 -0
- package/src/utils/url-helpers.test.js +84 -0
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
const WebSocket = require('ws');
|
|
2
|
+
|
|
3
|
+
class CLIWebSocketClient {
|
|
4
|
+
constructor(websocketUrl, token, verbose = false) {
|
|
5
|
+
this.websocketUrl = websocketUrl;
|
|
6
|
+
this.token = token;
|
|
7
|
+
this.verbose = verbose;
|
|
8
|
+
this.ws = null;
|
|
9
|
+
this.channel = null;
|
|
10
|
+
this.listeners = {};
|
|
11
|
+
this.heartbeatInterval = null;
|
|
12
|
+
this.heartbeatRef = 0;
|
|
13
|
+
this.reconnectAttempts = 0;
|
|
14
|
+
this.maxReconnectAttempts = 5;
|
|
15
|
+
this.reconnectDelay = 1000; // Start with 1 second
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Connect to WebSocket
|
|
20
|
+
* @returns {Promise<void>}
|
|
21
|
+
*/
|
|
22
|
+
connect() {
|
|
23
|
+
return new Promise((resolve, reject) => {
|
|
24
|
+
// Phoenix WebSocket endpoint requires /websocket suffix for raw WebSocket connections
|
|
25
|
+
let wsUrl = this.websocketUrl;
|
|
26
|
+
if (!wsUrl.endsWith('/websocket')) {
|
|
27
|
+
wsUrl = wsUrl.replace(/\/?$/, '/websocket');
|
|
28
|
+
}
|
|
29
|
+
const url = `${wsUrl}?token=${encodeURIComponent(this.token)}`;
|
|
30
|
+
|
|
31
|
+
if (this.verbose) {
|
|
32
|
+
const Logger = require('../utils/logger');
|
|
33
|
+
Logger.info(`Connecting to WebSocket: ${url.replace(this.token, 'TOKEN')}`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
this.ws = new WebSocket(url);
|
|
37
|
+
|
|
38
|
+
this.ws.on('open', () => {
|
|
39
|
+
this.reconnectAttempts = 0;
|
|
40
|
+
this.reconnectDelay = 1000;
|
|
41
|
+
this.startHeartbeat();
|
|
42
|
+
resolve();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
this.ws.on('error', (error) => {
|
|
46
|
+
this.stopHeartbeat();
|
|
47
|
+
if (this.reconnectAttempts === 0) {
|
|
48
|
+
reject(error);
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
this.ws.on('message', (data) => {
|
|
53
|
+
this.handleMessage(data);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
this.ws.on('close', (code, reason) => {
|
|
57
|
+
this.stopHeartbeat();
|
|
58
|
+
if (this.listeners.close) {
|
|
59
|
+
this.listeners.close(code, reason);
|
|
60
|
+
}
|
|
61
|
+
// Attempt reconnection if not intentional close
|
|
62
|
+
if (code !== 1000 && this.reconnectAttempts < this.maxReconnectAttempts) {
|
|
63
|
+
this.attemptReconnect();
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Attempt to reconnect to WebSocket
|
|
71
|
+
*/
|
|
72
|
+
attemptReconnect() {
|
|
73
|
+
this.reconnectAttempts++;
|
|
74
|
+
const delay = Math.min(this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1), 30000);
|
|
75
|
+
|
|
76
|
+
const Logger = require('../utils/logger');
|
|
77
|
+
Logger.warning(`WebSocket closed. Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})...`);
|
|
78
|
+
|
|
79
|
+
setTimeout(() => {
|
|
80
|
+
if (this.ws && this.ws.readyState === WebSocket.CLOSED) {
|
|
81
|
+
this.connect()
|
|
82
|
+
.then(() => {
|
|
83
|
+
Logger.success('WebSocket reconnected');
|
|
84
|
+
// Rejoin channel if we had one
|
|
85
|
+
if (this.channel) {
|
|
86
|
+
this.joinChannel(this.channel).catch(err => {
|
|
87
|
+
Logger.error(`Failed to rejoin channel: ${err.message}`);
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
})
|
|
91
|
+
.catch(err => {
|
|
92
|
+
Logger.error(`Reconnection failed: ${err.message}`);
|
|
93
|
+
if (this.reconnectAttempts < this.maxReconnectAttempts) {
|
|
94
|
+
this.attemptReconnect();
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
}, delay);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Start sending Phoenix heartbeats
|
|
103
|
+
*/
|
|
104
|
+
startHeartbeat() {
|
|
105
|
+
this.heartbeatInterval = setInterval(() => {
|
|
106
|
+
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
107
|
+
this.heartbeatRef++;
|
|
108
|
+
const heartbeat = JSON.stringify({
|
|
109
|
+
topic: 'phoenix',
|
|
110
|
+
event: 'heartbeat',
|
|
111
|
+
payload: {},
|
|
112
|
+
ref: `heartbeat-${this.heartbeatRef}`
|
|
113
|
+
});
|
|
114
|
+
this.ws.send(heartbeat);
|
|
115
|
+
}
|
|
116
|
+
}, 30000); // Every 30 seconds
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Stop sending heartbeats
|
|
121
|
+
*/
|
|
122
|
+
stopHeartbeat() {
|
|
123
|
+
if (this.heartbeatInterval) {
|
|
124
|
+
clearInterval(this.heartbeatInterval);
|
|
125
|
+
this.heartbeatInterval = null;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Join a Phoenix channel (CLI webhook channel)
|
|
131
|
+
* @param {string} channelName - Channel name (e.g., "cli_webhooks:user_id")
|
|
132
|
+
* @returns {Promise<object>} - Join response
|
|
133
|
+
*/
|
|
134
|
+
joinChannel(channelName) {
|
|
135
|
+
return new Promise((resolve, reject) => {
|
|
136
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
137
|
+
reject(new Error('WebSocket is not connected'));
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const joinRef = '1';
|
|
142
|
+
|
|
143
|
+
const joinMsg = JSON.stringify({
|
|
144
|
+
topic: channelName,
|
|
145
|
+
event: 'phx_join',
|
|
146
|
+
payload: {},
|
|
147
|
+
ref: joinRef
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
let resolved = false;
|
|
151
|
+
|
|
152
|
+
const handleJoinResponse = (data) => {
|
|
153
|
+
if (resolved) return;
|
|
154
|
+
|
|
155
|
+
try {
|
|
156
|
+
const msg = JSON.parse(data.toString());
|
|
157
|
+
if (msg.event === 'phx_reply' && msg.ref === joinRef && msg.topic === channelName) {
|
|
158
|
+
resolved = true;
|
|
159
|
+
this.ws.removeListener('message', handleJoinResponse);
|
|
160
|
+
|
|
161
|
+
if (msg.payload.status === 'ok') {
|
|
162
|
+
this.channel = channelName;
|
|
163
|
+
resolve(msg.payload);
|
|
164
|
+
} else {
|
|
165
|
+
reject(new Error(`Failed to join channel: ${msg.payload.response || 'Unknown error'}`));
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
} catch (error) {
|
|
169
|
+
// Ignore parse errors
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
this.ws.on('message', handleJoinResponse);
|
|
174
|
+
this.ws.send(joinMsg);
|
|
175
|
+
|
|
176
|
+
setTimeout(() => {
|
|
177
|
+
if (!resolved) {
|
|
178
|
+
resolved = true;
|
|
179
|
+
this.ws.removeListener('message', handleJoinResponse);
|
|
180
|
+
reject(new Error('Channel join timeout'));
|
|
181
|
+
}
|
|
182
|
+
}, 10000);
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Handle incoming WebSocket messages
|
|
188
|
+
* @param {Buffer} data - Message data
|
|
189
|
+
*/
|
|
190
|
+
handleMessage(data) {
|
|
191
|
+
try {
|
|
192
|
+
const msgStr = data.toString();
|
|
193
|
+
const msg = JSON.parse(msgStr);
|
|
194
|
+
|
|
195
|
+
const Logger = require('../utils/logger');
|
|
196
|
+
|
|
197
|
+
// Handle webhook events
|
|
198
|
+
if (msg.event === 'webhook') {
|
|
199
|
+
if (this.verbose) {
|
|
200
|
+
Logger.info(`\n📨 Webhook event received on channel: ${msg.topic}`);
|
|
201
|
+
}
|
|
202
|
+
if (this.listeners.webhook) {
|
|
203
|
+
this.listeners.webhook(msg.payload);
|
|
204
|
+
}
|
|
205
|
+
} else if (msg.event === 'phx_reply') {
|
|
206
|
+
// Handle join responses
|
|
207
|
+
if (this.listeners.join) {
|
|
208
|
+
this.listeners.join(msg);
|
|
209
|
+
}
|
|
210
|
+
} else if (this.verbose && msg.event !== 'heartbeat') {
|
|
211
|
+
Logger.info(`Received event: ${msg.event} on topic: ${msg.topic}`);
|
|
212
|
+
}
|
|
213
|
+
} catch (error) {
|
|
214
|
+
if (this.verbose) {
|
|
215
|
+
const Logger = require('../utils/logger');
|
|
216
|
+
Logger.warning(`Failed to parse WebSocket message: ${error.message}`);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Listen for webhook events
|
|
223
|
+
* @param {Function} callback - Callback function
|
|
224
|
+
*/
|
|
225
|
+
onWebhook(callback) {
|
|
226
|
+
this.listeners.webhook = callback;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Listen for close events
|
|
231
|
+
* @param {Function} callback - Callback function
|
|
232
|
+
*/
|
|
233
|
+
onClose(callback) {
|
|
234
|
+
this.listeners.close = callback;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Close WebSocket connection
|
|
239
|
+
*/
|
|
240
|
+
close() {
|
|
241
|
+
if (this.ws) {
|
|
242
|
+
this.ws.close(1000, 'CLI shutdown');
|
|
243
|
+
this.stopHeartbeat();
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
module.exports = CLIWebSocketClient;
|
|
249
|
+
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const os = require('os');
|
|
4
|
+
|
|
5
|
+
class Config {
|
|
6
|
+
constructor() {
|
|
7
|
+
this.configFile = this.findConfigFile();
|
|
8
|
+
this.config = this.loadConfig();
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
findConfigFile() {
|
|
12
|
+
// Check for .modelriverrc in current directory
|
|
13
|
+
const localRc = path.join(process.cwd(), '.modelriverrc');
|
|
14
|
+
if (fs.existsSync(localRc)) {
|
|
15
|
+
return localRc;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Check for ~/.modelriver/config.json
|
|
19
|
+
const homeConfig = path.join(os.homedir(), '.modelriver', 'config.json');
|
|
20
|
+
if (fs.existsSync(homeConfig)) {
|
|
21
|
+
return homeConfig;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
loadConfig() {
|
|
28
|
+
if (!this.configFile) {
|
|
29
|
+
return {};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
const content = fs.readFileSync(this.configFile, 'utf8');
|
|
34
|
+
return JSON.parse(content);
|
|
35
|
+
} catch (error) {
|
|
36
|
+
return {};
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
get(key, defaultValue = null) {
|
|
41
|
+
// Priority: env var > config file > default
|
|
42
|
+
const envKey = `MODELRIVER_${key.toUpperCase().replace(/-/g, '_')}`;
|
|
43
|
+
|
|
44
|
+
if (process.env[envKey]) {
|
|
45
|
+
return process.env[envKey].trim();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const configKey = key.replace(/-/g, '_');
|
|
49
|
+
if (this.config[configKey]) {
|
|
50
|
+
return this.config[configKey].trim();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return defaultValue;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
getApiKey() {
|
|
57
|
+
return this.get('api-key') || this.get('api_key');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
getProjectId() {
|
|
61
|
+
return this.get('project-id') || this.get('project_id');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
getApiUrl() {
|
|
65
|
+
return this.get('api-url', 'https://api.modelriver.com') || this.get('api_url', 'https://api.modelriver.com');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
getForwardUrl() {
|
|
69
|
+
return this.get('forward-url') || this.get('forward_url');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
getWebSocketUrl() {
|
|
73
|
+
const apiUrl = this.getApiUrl();
|
|
74
|
+
// Convert http:// to ws:// and https:// to wss://
|
|
75
|
+
if (apiUrl.startsWith('https://')) {
|
|
76
|
+
return apiUrl.replace('https://', 'wss://');
|
|
77
|
+
}
|
|
78
|
+
return apiUrl.replace('http://', 'ws://');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Get the path to the home config file
|
|
83
|
+
* @returns {string} - Path to ~/.modelriver/config.json
|
|
84
|
+
*/
|
|
85
|
+
getHomeConfigPath() {
|
|
86
|
+
return path.join(os.homedir(), '.modelriver', 'config.json');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Save configuration to ~/.modelriver/config.json
|
|
91
|
+
* @param {object} configData - Configuration object to save
|
|
92
|
+
*/
|
|
93
|
+
saveConfig(configData) {
|
|
94
|
+
const configDir = path.join(os.homedir(), '.modelriver');
|
|
95
|
+
const configPath = this.getHomeConfigPath();
|
|
96
|
+
|
|
97
|
+
// Create directory if it doesn't exist
|
|
98
|
+
if (!fs.existsSync(configDir)) {
|
|
99
|
+
fs.mkdirSync(configDir, { recursive: true });
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Load existing config and merge
|
|
103
|
+
let existingConfig = {};
|
|
104
|
+
if (fs.existsSync(configPath)) {
|
|
105
|
+
try {
|
|
106
|
+
const content = fs.readFileSync(configPath, 'utf8');
|
|
107
|
+
existingConfig = JSON.parse(content);
|
|
108
|
+
} catch (error) {
|
|
109
|
+
// Ignore parse errors, start fresh
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Merge with new config data
|
|
114
|
+
const mergedConfig = { ...existingConfig, ...configData };
|
|
115
|
+
|
|
116
|
+
// Write to file
|
|
117
|
+
fs.writeFileSync(configPath, JSON.stringify(mergedConfig, null, 2), 'utf8');
|
|
118
|
+
|
|
119
|
+
// Reload config
|
|
120
|
+
this.configFile = configPath;
|
|
121
|
+
this.config = mergedConfig;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Save a single configuration value
|
|
126
|
+
* @param {string} key - Configuration key
|
|
127
|
+
* @param {string} value - Configuration value
|
|
128
|
+
*/
|
|
129
|
+
save(key, value) {
|
|
130
|
+
const configKey = key.replace(/-/g, '_');
|
|
131
|
+
this.saveConfig({ [configKey]: value });
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
module.exports = new Config();
|
|
136
|
+
|
|
137
|
+
|
|
@@ -0,0 +1,98 @@
|
|
|
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
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
const crypto = require('crypto');
|
|
2
|
+
|
|
3
|
+
class WebhookVerifier {
|
|
4
|
+
/**
|
|
5
|
+
* Generate HMAC-SHA256 signature for webhook payload
|
|
6
|
+
* Matches backend implementation in lib/modelriver/workers/webhook_delivery_worker.ex
|
|
7
|
+
*/
|
|
8
|
+
static generateSignature(payload, secret, timestamp) {
|
|
9
|
+
// Create signature payload: timestamp.json_payload
|
|
10
|
+
const jsonPayload = JSON.stringify(payload);
|
|
11
|
+
const signaturePayload = `${timestamp}.${jsonPayload}`;
|
|
12
|
+
|
|
13
|
+
// Generate HMAC-SHA256
|
|
14
|
+
const hmac = crypto.createHmac('sha256', secret);
|
|
15
|
+
hmac.update(signaturePayload);
|
|
16
|
+
return hmac.digest('hex');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Verify webhook signature
|
|
21
|
+
* @param {string} providedSignature - Signature from X-ModelRiver-Signature header
|
|
22
|
+
* @param {object} payload - The payload.data object from webhook body
|
|
23
|
+
* @param {string} secret - Webhook secret
|
|
24
|
+
* @param {string|number} timestamp - Timestamp from X-ModelRiver-Timestamp header
|
|
25
|
+
* @returns {boolean} - True if signature is valid
|
|
26
|
+
*/
|
|
27
|
+
static verify(providedSignature, payload, secret, timestamp) {
|
|
28
|
+
const expectedSignature = this.generateSignature(payload, secret, timestamp);
|
|
29
|
+
return this.secureCompare(providedSignature, expectedSignature);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Constant-time comparison to prevent timing attacks
|
|
34
|
+
* Matches backend implementation
|
|
35
|
+
*/
|
|
36
|
+
static secureCompare(left, right) {
|
|
37
|
+
if (typeof left !== 'string' || typeof right !== 'string') {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (left.length !== right.length) {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
let result = 0;
|
|
46
|
+
for (let i = 0; i < left.length; i++) {
|
|
47
|
+
result |= left.charCodeAt(i) ^ right.charCodeAt(i);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return result === 0;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
module.exports = WebhookVerifier;
|
|
55
|
+
|
|
56
|
+
|
|
@@ -0,0 +1,90 @@
|
|
|
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
|
+
});
|