launchpd 1.0.3 → 1.0.6

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/src/utils/api.js CHANGED
@@ -3,283 +3,308 @@
3
3
  * Communicates with the Cloudflare Worker API endpoints
4
4
  */
5
5
 
6
- import { config } from '../config.js';
7
- import { getApiKey, getApiSecret } from './credentials.js';
8
- import { createHmac } from 'node:crypto';
9
- import { getMachineId } from './machineId.js';
6
+ import { config } from '../config.js'
7
+ import { getApiKey, getApiSecret } from './credentials.js'
8
+ import { createHmac } from 'node:crypto'
9
+ import { getMachineId } from './machineId.js'
10
+ import { validateEndpoint } from './endpoint.js'
10
11
  import {
11
- APIError,
12
- MaintenanceError,
13
- AuthError,
14
- NetworkError,
15
- TwoFactorRequiredError
16
- } from './errors.js';
12
+ APIError,
13
+ MaintenanceError,
14
+ AuthError,
15
+ NetworkError,
16
+ TwoFactorRequiredError
17
+ } from './errors.js'
17
18
 
18
- const API_BASE_URL = config.apiUrl;
19
+ const API_BASE_URL = config.apiUrl
20
+
21
+ /** Fetch timeout for LaunchPd API requests in milliseconds (30 seconds) */
22
+ export const API_TIMEOUT_MS = 30_000
19
23
 
20
24
  // Re-export error classes for convenience
21
25
  export {
22
- APIError,
23
- MaintenanceError,
24
- AuthError,
25
- NetworkError,
26
- TwoFactorRequiredError
27
- } from './errors.js';
26
+ APIError,
27
+ MaintenanceError,
28
+ AuthError,
29
+ NetworkError,
30
+ TwoFactorRequiredError
31
+ } from './errors.js'
32
+
33
+ /**
34
+ * Create an AbortController with a timeout.
35
+ * @param {number} ms - Timeout in milliseconds
36
+ * @returns {{ signal: AbortSignal, clear: () => void }}
37
+ */
38
+ export function createFetchTimeout (ms) {
39
+ const controller = new AbortController()
40
+ const timer = setTimeout(() => controller.abort(), ms)
41
+ return {
42
+ signal: controller.signal,
43
+ clear: () => clearTimeout(timer)
44
+ }
45
+ }
28
46
 
29
47
  /**
30
48
  * Make an authenticated API request
31
49
  */
32
- async function apiRequest(endpoint, options = {}) {
33
- const url = `${API_BASE_URL}${endpoint}`;
34
-
35
- const apiKey = await getApiKey();
36
- const apiSecret = await getApiSecret();
37
- const headers = {
38
- 'Content-Type': 'application/json',
39
- 'X-API-Key': apiKey,
40
- 'X-Device-Fingerprint': getMachineId(),
41
- ...options.headers,
42
- };
43
-
44
- // Add HMAC signature if secret is available
45
- if (apiSecret) {
46
- const timestamp = Date.now().toString();
47
- const method = (options.method || 'GET').toUpperCase();
48
- const body = options.body || '';
49
-
50
- // HMAC-SHA256(secret, method + path + timestamp + body)
51
- const hmac = createHmac('sha256', apiSecret);
52
- hmac.update(method);
53
- hmac.update(endpoint);
54
- hmac.update(timestamp);
55
- hmac.update(body);
56
-
57
- const signature = hmac.digest('hex');
58
-
59
- headers['X-Timestamp'] = timestamp;
60
- headers['X-Signature'] = signature;
50
+ export async function apiRequest (endpoint, options = {}) {
51
+ validateEndpoint(endpoint)
52
+ const url = `${API_BASE_URL}${endpoint}`
53
+
54
+ const apiKey = await getApiKey()
55
+ const apiSecret = await getApiSecret()
56
+ const headers = {
57
+ 'Content-Type': 'application/json',
58
+ 'X-API-Key': apiKey,
59
+ 'X-Device-Fingerprint': getMachineId(),
60
+ ...options.headers
61
+ }
62
+
63
+ // Add HMAC signature if secret is available
64
+ if (apiSecret) {
65
+ const timestamp = Date.now().toString()
66
+ const method = (options.method || 'GET').toUpperCase()
67
+ const body = options.body || ''
68
+
69
+ // HMAC-SHA256 for REQUEST SIGNING - this is NOT password hashing.
70
+ // The request body (which may contain passwords) is signed to authenticate
71
+ // the API request. Password hashing happens server-side using bcrypt/argon2.
72
+ // skipcq: JS-D003 - HMAC-SHA256 is appropriate for request signing
73
+ const hmac = createHmac('sha256', apiSecret)
74
+ hmac.update(method)
75
+ hmac.update(endpoint)
76
+ hmac.update(timestamp)
77
+ // skipcq: JS-D003 - Request body signing, not password storage
78
+ hmac.update(body)
79
+
80
+ const signature = hmac.digest('hex')
81
+
82
+ headers['X-Timestamp'] = timestamp
83
+ headers['X-Signature'] = signature
84
+ }
85
+
86
+ const { signal, clear } = createFetchTimeout(API_TIMEOUT_MS)
87
+ try {
88
+ const response = await fetch(url, {
89
+ ...options,
90
+ headers,
91
+ signal
92
+ })
93
+
94
+ // Handle maintenance mode (503 with maintenance_mode flag)
95
+ if (response.status === 503) {
96
+ const data = await response.json().catch(() => ({}))
97
+ if (data.maintenance_mode) {
98
+ throw new MaintenanceError(
99
+ data.message || 'LaunchPd is under maintenance'
100
+ )
101
+ }
102
+ throw new APIError(data.message || 'Service unavailable', 503, data)
103
+ }
104
+
105
+ // Handle authentication errors
106
+ if (response.status === 401) {
107
+ const data = await response.json().catch(() => ({}))
108
+ // Check if 2FA is required (special case - not a real auth error)
109
+ if (data.requires_2fa) {
110
+ throw new TwoFactorRequiredError(data.two_factor_type, data.message)
111
+ }
112
+ throw new AuthError(data.message || 'Authentication failed', data)
113
+ }
114
+
115
+ // Handle rate limiting / quota errors
116
+ if (response.status === 429) {
117
+ const data = await response.json().catch(() => ({}))
118
+ throw new APIError(data.message || 'Rate limit exceeded', 429, data)
119
+ }
120
+
121
+ const data = await response.json()
122
+
123
+ if (!response.ok) {
124
+ throw new APIError(
125
+ data.error || data.message || `API error: ${response.status}`,
126
+ response.status,
127
+ data
128
+ )
61
129
  }
62
130
 
63
- try {
64
- const response = await fetch(url, {
65
- ...options,
66
- headers,
67
- });
68
-
69
- // Handle maintenance mode (503 with maintenance_mode flag)
70
- if (response.status === 503) {
71
- const data = await response.json().catch(() => ({}));
72
- if (data.maintenance_mode) {
73
- throw new MaintenanceError(data.message || 'LaunchPd is under maintenance');
74
- }
75
- throw new APIError(data.message || 'Service unavailable', 503, data);
76
- }
77
-
78
- // Handle authentication errors
79
- if (response.status === 401) {
80
- const data = await response.json().catch(() => ({}));
81
- // Check if 2FA is required (special case - not a real auth error)
82
- if (data.requires_2fa) {
83
- throw new TwoFactorRequiredError(data.two_factor_type, data.message);
84
- }
85
- throw new AuthError(data.message || 'Authentication failed', data);
86
- }
87
-
88
- // Handle rate limiting / quota errors
89
- if (response.status === 429) {
90
- const data = await response.json().catch(() => ({}));
91
- throw new APIError(data.message || 'Rate limit exceeded', 429, data);
92
- }
93
-
94
- const data = await response.json();
95
-
96
- if (!response.ok) {
97
- throw new APIError(data.error || data.message || `API error: ${response.status}`, response.status, data);
98
- }
99
-
100
- return data;
101
- } catch (err) {
102
- // Re-throw our custom errors
103
- if (err instanceof APIError || err instanceof NetworkError) {
104
- throw err;
105
- }
106
- // If API is unavailable, throw NetworkError for consistent handling
107
- if (err.message.includes('fetch failed') || err.message.includes('ENOTFOUND') || err.message.includes('ECONNREFUSED')) {
108
- throw new NetworkError('Unable to connect to LaunchPd servers');
109
- }
110
- throw err;
131
+ return data
132
+ } catch (err) {
133
+ // Re-throw our custom errors
134
+ if (err instanceof APIError || err instanceof NetworkError) {
135
+ throw err
111
136
  }
137
+ // Handle fetch timeout
138
+ if (err.name === 'AbortError') {
139
+ throw new NetworkError(
140
+ `Request timed out after ${API_TIMEOUT_MS / 1000}s. The server did not respond in time.`
141
+ )
142
+ }
143
+ // If API is unavailable, throw NetworkError for consistent handling
144
+ if (
145
+ err.message.includes('fetch failed') ||
146
+ err.message.includes('ENOTFOUND') ||
147
+ err.message.includes('ECONNREFUSED')
148
+ ) {
149
+ throw new NetworkError('Unable to connect to LaunchPd servers')
150
+ }
151
+ throw err
152
+ } finally {
153
+ clear()
154
+ }
112
155
  }
113
156
 
114
157
  /**
115
158
  * Get the next version number for a subdomain
116
159
  */
117
- export async function getNextVersionFromAPI(subdomain) {
118
- const result = await apiRequest(`/api/versions/${subdomain}`);
119
- if (!result || !result.versions || result.versions.length === 0) {
120
- return 1;
121
- }
122
- const maxVersion = Math.max(...result.versions.map(v => v.version));
123
- return maxVersion + 1;
160
+ export async function getNextVersionFromAPI (subdomain) {
161
+ const result = await apiRequest(`/api/versions/${subdomain}`)
162
+ if (!result?.versions?.length) return 1
163
+ const maxVersion = Math.max(...result.versions.map((v) => v.version))
164
+ return maxVersion + 1
124
165
  }
125
166
 
126
167
  /**
127
168
  * Record a new deployment in the API
128
169
  */
129
- export async function recordDeployment(deploymentData) {
130
- const { subdomain, folderName, fileCount, totalBytes, version, expiresAt, message } = deploymentData;
131
-
132
- return await apiRequest('/api/deployments', {
133
- method: 'POST',
134
- body: JSON.stringify({
135
- subdomain,
136
- folderName,
137
- fileCount,
138
- totalBytes,
139
- version,
140
- cliVersion: config.version,
141
- expiresAt,
142
- message,
143
- }),
144
- });
170
+ export function recordDeployment (deploymentData) {
171
+ const {
172
+ subdomain,
173
+ folderName,
174
+ fileCount,
175
+ totalBytes,
176
+ version,
177
+ expiresAt,
178
+ message
179
+ } = deploymentData
180
+
181
+ return apiRequest('/api/deployments', {
182
+ method: 'POST',
183
+ body: JSON.stringify({
184
+ subdomain,
185
+ folderName,
186
+ fileCount,
187
+ totalBytes,
188
+ version,
189
+ cliVersion: config.version,
190
+ expiresAt,
191
+ message
192
+ })
193
+ })
145
194
  }
146
195
 
147
196
  /**
148
197
  * Get list of user's deployments
149
198
  */
150
- export async function listDeployments(limit = 50, offset = 0) {
151
- return await apiRequest(`/api/deployments?limit=${limit}&offset=${offset}`);
199
+ export function listDeployments (limit = 50, offset = 0) {
200
+ return apiRequest(`/api/deployments?limit=${limit}&offset=${offset}`)
152
201
  }
153
202
 
154
203
  /**
155
204
  * Get deployment details for a subdomain
156
205
  */
157
- export async function getDeployment(subdomain) {
158
- return await apiRequest(`/api/deployments/${subdomain}`);
206
+ export function getDeployment (subdomain) {
207
+ return apiRequest(`/api/deployments/${subdomain}`)
159
208
  }
160
209
 
161
210
  /**
162
211
  * Get version history for a subdomain
163
212
  */
164
- export async function getVersions(subdomain) {
165
- return await apiRequest(`/api/versions/${subdomain}`);
213
+ export function getVersions (subdomain) {
214
+ return apiRequest(`/api/versions/${subdomain}`)
166
215
  }
167
216
 
168
217
  /**
169
218
  * Rollback to a specific version
170
219
  */
171
- export async function rollbackVersion(subdomain, version) {
172
- return await apiRequest(`/api/versions/${subdomain}/rollback`, {
173
- method: 'PUT',
174
- body: JSON.stringify({ version }),
175
- });
220
+ export function rollbackVersion (subdomain, version) {
221
+ return apiRequest(`/api/versions/${subdomain}/rollback`, {
222
+ method: 'PUT',
223
+ body: JSON.stringify({ version })
224
+ })
176
225
  }
177
226
 
178
227
  /**
179
228
  * Check if a subdomain is available
180
229
  */
181
- export async function checkSubdomainAvailable(subdomain) {
182
- const result = await apiRequest(`/api/public/check/${subdomain}`);
183
- return result?.available ?? true;
230
+ export async function checkSubdomainAvailable (subdomain) {
231
+ const result = await apiRequest(`/api/public/check/${subdomain}`)
232
+ return result?.available ?? true
184
233
  }
185
234
 
186
235
  /**
187
236
  * Reserve a subdomain
188
237
  */
189
- export async function reserveSubdomain(subdomain) {
190
- return await apiRequest('/api/subdomains/reserve', {
191
- method: 'POST',
192
- body: JSON.stringify({ subdomain }),
193
- });
238
+ export function reserveSubdomain (subdomain) {
239
+ return apiRequest('/api/subdomains/reserve', {
240
+ method: 'POST',
241
+ body: JSON.stringify({ subdomain })
242
+ })
194
243
  }
195
244
 
196
245
  /**
197
246
  * Unreserve a subdomain
198
247
  */
199
- export async function unreserveSubdomain(subdomain) {
200
- // Note: Admin only, but good to have in client client lib
201
- return await apiRequest(`/api/admin/reserve-subdomain/${subdomain}`, {
202
- method: 'DELETE',
203
- });
248
+ export function unreserveSubdomain (subdomain) {
249
+ // Note: Admin only, but good to have in client client lib
250
+ return apiRequest(`/api/admin/reserve-subdomain/${subdomain}`, {
251
+ method: 'DELETE'
252
+ })
204
253
  }
205
254
 
206
255
  /**
207
256
  * Get user's subdomains
208
257
  */
209
- export async function listSubdomains() {
210
- return await apiRequest('/api/subdomains');
258
+ export function listSubdomains () {
259
+ return apiRequest('/api/subdomains')
211
260
  }
212
261
 
213
262
  /**
214
263
  * Get current user info
215
264
  */
216
- export async function getCurrentUser() {
217
- return await apiRequest('/api/users/me');
265
+ export function getCurrentUser () {
266
+ return apiRequest('/api/users/me')
218
267
  }
219
268
 
220
269
  /**
221
270
  * Health check
222
271
  */
223
- export async function healthCheck() {
224
- return await apiRequest('/api/health');
272
+ export function healthCheck () {
273
+ return apiRequest('/api/health')
225
274
  }
226
275
 
227
276
  /**
228
277
  * Resend email verification
229
278
  */
230
- export async function resendVerification() {
231
- return await apiRequest('/api/auth/resend-verification', {
232
- method: 'POST',
233
- });
279
+ export function resendVerification () {
280
+ return apiRequest('/api/auth/resend-verification', {
281
+ method: 'POST'
282
+ })
234
283
  }
235
284
 
236
285
  /**
237
286
  * Regenerate API key
238
287
  */
239
- export async function regenerateApiKey() {
240
- return await apiRequest('/api/api-key/regenerate', {
241
- method: 'POST',
242
- body: JSON.stringify({ confirm: 'yes' }),
243
- });
244
- }
245
-
246
- /**
247
- * Change password
248
- */
249
- export async function changePassword(currentPassword, newPassword, confirmPassword) {
250
- return await apiRequest('/api/settings/change-password', {
251
- method: 'POST',
252
- body: JSON.stringify({
253
- current_password: currentPassword,
254
- new_password: newPassword,
255
- confirm_password: confirmPassword,
256
- }),
257
- });
258
- }
259
-
260
- /**
261
- * Server-side logout
262
- */
263
- export async function serverLogout() {
264
- return await apiRequest('/api/auth/logout', {
265
- method: 'POST',
266
- });
288
+ export function regenerateApiKey () {
289
+ return apiRequest('/api/api-key/regenerate', {
290
+ method: 'POST',
291
+ body: JSON.stringify({ confirm: 'yes' })
292
+ })
267
293
  }
268
294
 
269
295
  export default {
270
- recordDeployment,
271
- listDeployments,
272
- getDeployment,
273
- getVersions,
274
- rollbackVersion,
275
- checkSubdomainAvailable,
276
- reserveSubdomain,
277
- unreserveSubdomain,
278
- listSubdomains,
279
- getCurrentUser,
280
- healthCheck,
281
- resendVerification,
282
- regenerateApiKey,
283
- changePassword,
284
- serverLogout,
285
- };
296
+ apiRequest,
297
+ recordDeployment,
298
+ listDeployments,
299
+ getDeployment,
300
+ getVersions,
301
+ rollbackVersion,
302
+ checkSubdomainAvailable,
303
+ reserveSubdomain,
304
+ unreserveSubdomain,
305
+ listSubdomains,
306
+ getCurrentUser,
307
+ healthCheck,
308
+ resendVerification,
309
+ regenerateApiKey
310
+ }