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.
@@ -3,11 +3,12 @@
3
3
  * Validates user can deploy before uploading
4
4
  */
5
5
 
6
- import { config } from '../config.js';
7
- import { getCredentials, getClientToken } from './credentials.js';
8
- import { warning, error, info, log, raw } from './logger.js';
6
+ import { config } from '../config.js'
7
+ import { getCredentials, getClientToken } from './credentials.js'
8
+ import { warning, error, info, log, raw } from './logger.js'
9
+ import { createFetchTimeout, API_TIMEOUT_MS } from './api.js'
9
10
 
10
- const API_BASE_URL = `https://api.${config.domain}`;
11
+ const API_BASE_URL = `https://api.${config.domain}`
11
12
 
12
13
  /**
13
14
  * Check quota before deployment
@@ -19,267 +20,302 @@ const API_BASE_URL = `https://api.${config.domain}`;
19
20
  * @param {boolean} options.isUpdate - Whether this is known to be an update
20
21
  * @returns {Promise<{allowed: boolean, isNewSite: boolean, quota: object, warnings: string[]}>}
21
22
  */
22
- export async function checkQuota(subdomain, estimatedBytes = 0, options = {}) {
23
- const creds = await getCredentials();
24
-
25
- let quotaData;
26
-
27
- if (creds?.apiKey) {
28
- // Authenticated user
29
- quotaData = await checkAuthenticatedQuota(creds.apiKey, options.isUpdate);
30
- } else {
31
- // Anonymous user
32
- quotaData = await checkAnonymousQuota();
33
- }
34
- // ... skipped ...
35
- /**
36
- * Check quota for authenticated user
37
- */
38
- async function checkAuthenticatedQuota(apiKey, isUpdate = false) {
39
- try {
40
- const url = new URL(`${API_BASE_URL}/api/quota`);
41
- if (isUpdate) {
42
- url.searchParams.append('is_update', 'true');
43
- }
44
-
45
- const response = await fetch(url.toString(), {
46
- headers: {
47
- 'X-API-Key': apiKey,
48
- },
49
- });
50
-
51
- if (!response.ok) {
52
- if (options.verbose || process.env.DEBUG) {
53
- raw(`Quota check failed: ${response.status} ${response.statusText}`, 'error');
54
- const text = await response.text();
55
- raw(`Response: ${text}`, 'error');
56
- }
57
- return null;
58
- }
59
-
60
- return await response.json();
61
- } catch (err) {
62
- if (options.verbose || process.env.DEBUG) {
63
- raw('Quota check error:', 'error');
64
- raw(err, 'error');
65
- if (err.cause) raw('Cause:', 'error');
66
- if (err.cause) raw(err.cause, 'error');
67
- }
68
- return null;
69
- }
70
- }
71
- // ... skipped ...
72
-
73
- if (!quotaData) {
74
- // API unavailable, allow deployment (fail-open for MVP)
75
- return {
76
- allowed: true,
77
- isNewSite: true,
78
- quota: null,
79
- warnings: ['Could not verify quota (API unavailable)'],
80
- };
81
- }
82
-
83
- // DEBUG: Write input options to file
23
+ export async function checkQuota (subdomain, estimatedBytes = 0, options = {}) {
24
+ const creds = await getCredentials()
25
+
26
+ let quotaData
27
+
28
+ if (creds?.apiKey) {
29
+ // Authenticated user
30
+ quotaData = await checkAuthenticatedQuota(creds.apiKey, options.isUpdate)
31
+ } else {
32
+ // Anonymous user
33
+ quotaData = await checkAnonymousQuota()
34
+ }
35
+ // ... skipped ...
36
+ /**
37
+ * Check quota for authenticated user
38
+ */
39
+ async function checkAuthenticatedQuota (apiKey, isUpdate = false) {
40
+ const { signal, clear } = createFetchTimeout(API_TIMEOUT_MS)
84
41
  try {
85
- const { appendFileSync } = await import('node:fs');
86
- appendFileSync('quota_debug_trace.txt', `\n[${new Date().toISOString()}] Check: ${subdomain}, isUpdate: ${options.isUpdate}, type: ${typeof options.isUpdate}`);
87
- } catch {
88
- // Ignore trace errors
89
- }
90
-
91
- // Check if this is an existing site the user owns
92
- // If explicitly marked as update, assume user owns it
93
- let isNewSite = true;
94
- if (options.isUpdate) {
95
- isNewSite = false;
96
- } else if (subdomain) {
97
- isNewSite = !await userOwnsSite(creds?.apiKey, subdomain);
98
- }
99
-
100
- const warnings = [...(quotaData.warnings || [])];
101
-
102
- // Add quota warning (de-duplicated) - early so it shows even if blocked later
103
- const remaining = quotaData.usage?.sitesRemaining;
104
- if (typeof remaining === 'number') {
105
- const warningMsg = `You have ${remaining} site(s) remaining`;
106
- // Only push if not already present in warnings from backend
107
- if (!warnings.some(w => w.toLowerCase().includes('site(s) remaining'))) {
108
- warnings.push(warningMsg);
42
+ const url = new URL(`${API_BASE_URL}/api/quota`)
43
+ if (isUpdate) {
44
+ url.searchParams.append('is_update', 'true')
45
+ }
46
+
47
+ const response = await fetch(url.toString(), {
48
+ headers: {
49
+ 'X-API-Key': apiKey
50
+ },
51
+ signal
52
+ })
53
+
54
+ if (!response.ok) {
55
+ if (options.verbose || process.env.DEBUG) {
56
+ raw(
57
+ `Quota check failed: ${response.status} ${response.statusText}`,
58
+ 'error'
59
+ )
60
+ const text = await response.text()
61
+ raw(`Response: ${text}`, 'error')
109
62
  }
110
- }
111
-
112
- // Determine if deployment is allowed based on API flags or local calculations
113
- const allowed = quotaData.canDeploy !== undefined ? quotaData.canDeploy : true;
63
+ return null
64
+ }
114
65
 
115
- // Check if blocked (anonymous limit reached or explicitly blocked by backend)
116
- if (quotaData.blocked) {
117
- if (quotaData.upgradeMessage) log(quotaData.upgradeMessage);
118
- return {
119
- allowed: false,
120
- isNewSite,
121
- quota: quotaData,
122
- warnings: [],
123
- };
66
+ return await response.json()
67
+ } catch (err) {
68
+ if (options.verbose || process.env.DEBUG) {
69
+ raw('Quota check error:', 'error')
70
+ raw(err, 'error')
71
+ if (err.cause) raw('Cause:', 'error')
72
+ if (err.cause) raw(err.cause, 'error')
73
+ }
74
+ return null
75
+ } finally {
76
+ clear()
124
77
  }
78
+ }
79
+ // ... skipped ...
125
80
 
126
- // Check site limit for new sites
127
- if (isNewSite) {
128
- const canCreate = quotaData.canCreateNewSite !== undefined ? quotaData.canCreateNewSite : (remaining > 0);
129
- if (!canCreate) {
130
- error(`Site limit reached (${quotaData.limits?.maxSites || 'unknown'} sites)`);
131
- if (!creds?.apiKey) {
132
- showUpgradePrompt();
133
- } else {
134
- info('Upgrade to Pro for more sites, or delete an existing site');
135
- info('Check your quota status: launchpd whoami');
136
- }
137
- return {
138
- allowed: false,
139
- isNewSite,
140
- quota: quotaData,
141
- warnings,
142
- };
143
- }
81
+ if (!quotaData) {
82
+ // API unavailable, deny deployment (fail-closed)
83
+ return {
84
+ allowed: false,
85
+ isNewSite: true,
86
+ quota: null,
87
+ warnings: [
88
+ 'Could not verify quota (API unavailable). Please try again later.'
89
+ ]
144
90
  }
145
-
146
- // Check storage limit
147
- const maxStorage = quotaData.limits?.maxStorageBytes || (quotaData.limits?.maxStorageMB * 1024 * 1024);
148
- const storageUsed = quotaData.usage?.storageUsed || (quotaData.usage?.storageUsedMB * 1024 * 1024) || 0;
149
- const storageAfter = storageUsed + estimatedBytes;
150
-
151
- if (maxStorage && storageAfter > maxStorage) {
152
- const overBy = storageAfter - quotaData.limits.maxStorageBytes;
153
- error(`Storage limit exceeded by ${formatBytes(overBy)}`);
154
- error(`Current: ${formatBytes(quotaData.usage.storageUsed)} / ${formatBytes(quotaData.limits.maxStorageBytes)}`);
155
- if (!creds?.apiKey) {
156
- showUpgradePrompt();
157
- } else {
158
- info('Upgrade to Pro for more storage, or delete old deployments');
159
- }
160
- return {
161
- allowed: false,
162
- isNewSite,
163
- quota: quotaData,
164
- warnings,
165
- };
91
+ }
92
+
93
+ // Check if this is an existing site the user owns
94
+ // If explicitly marked as update, assume user owns it
95
+ let isNewSite = true
96
+ if (options.isUpdate) {
97
+ isNewSite = false
98
+ } else if (subdomain) {
99
+ isNewSite = !(await userOwnsSite(creds?.apiKey, subdomain))
100
+ }
101
+
102
+ const warnings = [...(quotaData.warnings || [])]
103
+
104
+ // Add quota warning (de-duplicated) - early so it shows even if blocked later
105
+ const remaining = quotaData.usage?.sitesRemaining
106
+ if (typeof remaining === 'number') {
107
+ const warningMsg = `You have ${remaining} site(s) remaining`
108
+ // Only push if not already present in warnings from backend
109
+ if (!warnings.some((w) => w.toLowerCase().includes('site(s) remaining'))) {
110
+ warnings.push(warningMsg)
166
111
  }
112
+ }
167
113
 
168
- // Add storage warning if close to limit
169
- const storagePercentage = storageAfter / quotaData.limits.maxStorageBytes;
170
- if (storagePercentage > 0.8) {
171
- warnings.push(`Storage ${Math.round(storagePercentage * 100)}% used (${formatBytes(storageAfter)} / ${formatBytes(quotaData.limits.maxStorageBytes)})`);
172
- }
114
+ // Determine if deployment is allowed based on API flags or local calculations
115
+ const allowed = quotaData.canDeploy ?? true
173
116
 
117
+ // Check if blocked (anonymous limit reached or explicitly blocked by backend)
118
+ if (quotaData.blocked) {
119
+ if (quotaData.upgradeMessage) log(quotaData.upgradeMessage)
174
120
  return {
175
- allowed,
121
+ allowed: false,
122
+ isNewSite,
123
+ quota: quotaData,
124
+ warnings: []
125
+ }
126
+ }
127
+
128
+ // Check site limit for new sites
129
+ if (isNewSite) {
130
+ const canCreate =
131
+ quotaData.canCreateNewSite === undefined
132
+ ? remaining > 0
133
+ : quotaData.canCreateNewSite
134
+ if (canCreate) {
135
+ // Site creation is allowed, continue
136
+ } else {
137
+ error(
138
+ `Site limit reached (${quotaData.limits?.maxSites || 'unknown'} sites)`
139
+ )
140
+ if (creds?.apiKey) {
141
+ info('Upgrade to Pro for more sites, or delete an existing site')
142
+ info('Check your quota status: launchpd whoami')
143
+ } else {
144
+ showUpgradePrompt()
145
+ }
146
+ return {
147
+ allowed: false,
176
148
  isNewSite,
177
149
  quota: quotaData,
178
- warnings,
179
- };
150
+ warnings
151
+ }
152
+ }
153
+ }
154
+
155
+ // Check storage limit
156
+ const maxStorage =
157
+ quotaData.limits?.maxStorageBytes ||
158
+ quotaData.limits?.maxStorageMB * 1024 * 1024
159
+ const storageUsed =
160
+ quotaData.usage?.storageUsed ||
161
+ quotaData.usage?.storageUsedMB * 1024 * 1024 ||
162
+ 0
163
+ const storageAfter = storageUsed + estimatedBytes
164
+
165
+ if (maxStorage && storageAfter > maxStorage) {
166
+ const overBy = storageAfter - quotaData.limits.maxStorageBytes
167
+ error(`Storage limit exceeded by ${formatBytes(overBy)}`)
168
+ error(
169
+ `Current: ${formatBytes(quotaData.usage.storageUsed)} / ${formatBytes(quotaData.limits.maxStorageBytes)}`
170
+ )
171
+ if (creds?.apiKey) {
172
+ info('Upgrade to Pro for more storage, or delete old deployments')
173
+ } else {
174
+ showUpgradePrompt()
175
+ }
176
+ return {
177
+ allowed: false,
178
+ isNewSite,
179
+ quota: quotaData,
180
+ warnings
181
+ }
182
+ }
183
+
184
+ // Add storage warning if close to limit
185
+ const storagePercentage = storageAfter / quotaData.limits.maxStorageBytes
186
+ if (storagePercentage > 0.8) {
187
+ warnings.push(
188
+ `Storage ${Math.round(storagePercentage * 100)}% used (${formatBytes(storageAfter)} / ${formatBytes(quotaData.limits.maxStorageBytes)})`
189
+ )
190
+ }
191
+
192
+ return {
193
+ allowed,
194
+ isNewSite,
195
+ quota: quotaData,
196
+ warnings
197
+ }
180
198
  }
181
199
 
182
-
183
200
  /**
184
201
  * Check quota for anonymous user
185
202
  */
186
- async function checkAnonymousQuota() {
187
- try {
188
- const clientToken = await getClientToken();
189
-
190
- const response = await fetch(`${API_BASE_URL}/api/quota/anonymous`, {
191
- method: 'POST',
192
- headers: {
193
- 'Content-Type': 'application/json',
194
- },
195
- body: JSON.stringify({
196
- clientToken,
197
- }),
198
- });
199
-
200
- if (!response.ok) {
201
- return null;
202
- }
203
+ async function checkAnonymousQuota () {
204
+ const { signal, clear } = createFetchTimeout(API_TIMEOUT_MS)
205
+ try {
206
+ const clientToken = await getClientToken()
207
+
208
+ // Validate client token format before sending to network
209
+ // This ensures we only send properly formatted tokens, not arbitrary file data
210
+ if (
211
+ !clientToken ||
212
+ typeof clientToken !== 'string' ||
213
+ !/^cli_[a-f0-9]{32}$/.test(clientToken)
214
+ ) {
215
+ return null
216
+ }
203
217
 
204
- return await response.json();
205
- } catch {
206
- return null;
218
+ const response = await fetch(`${API_BASE_URL}/api/quota/anonymous`, {
219
+ method: 'POST',
220
+ headers: {
221
+ 'Content-Type': 'application/json'
222
+ },
223
+ body: JSON.stringify({
224
+ clientToken
225
+ }),
226
+ signal
227
+ })
228
+
229
+ if (!response.ok) {
230
+ return null
207
231
  }
232
+
233
+ return await response.json()
234
+ } catch {
235
+ return null
236
+ } finally {
237
+ clear()
238
+ }
208
239
  }
209
240
 
210
241
  /**
211
242
  * Check if user owns a subdomain
212
243
  */
213
- async function userOwnsSite(apiKey, subdomain) {
214
- if (!apiKey) {
215
- // For anonymous, we track by client token in deployments
216
- return false;
244
+ async function userOwnsSite (apiKey, subdomain) {
245
+ if (!apiKey) {
246
+ // For anonymous, we track by client token in deployments
247
+ return false
248
+ }
249
+
250
+ const { signal, clear } = createFetchTimeout(API_TIMEOUT_MS)
251
+ try {
252
+ const response = await fetch(`${API_BASE_URL}/api/subdomains`, {
253
+ headers: {
254
+ 'X-API-Key': apiKey
255
+ },
256
+ signal
257
+ })
258
+
259
+ if (!response.ok) {
260
+ log(`Fetch subdomains failed: ${response.status}`)
261
+ return false
217
262
  }
218
263
 
219
- try {
220
- const response = await fetch(`${API_BASE_URL}/api/subdomains`, {
221
- headers: {
222
- 'X-API-Key': apiKey,
223
- },
224
- });
225
-
226
- if (!response.ok) {
227
- log(`Fetch subdomains failed: ${response.status}`);
228
- return false;
229
- }
230
-
231
- const data = await response.json();
232
- if (process.env.DEBUG) {
233
- log(`User subdomains: ${data.subdomains?.map(s => s.subdomain)}`);
234
- log(`Checking for: ${subdomain}`);
235
- }
236
- const owns = data.subdomains?.some(s => s.subdomain === subdomain) || false;
237
- if (process.env.DEBUG) {
238
- log(`Owns site? ${owns}`);
239
- }
240
- return owns;
241
- } catch (err) {
242
- log(`Error checking ownership: ${err.message}`);
243
- return false;
264
+ const data = await response.json()
265
+ if (process.env.DEBUG) {
266
+ log(`User subdomains: ${data.subdomains?.map((s) => s.subdomain)}`)
267
+ log(`Checking for: ${subdomain}`)
268
+ }
269
+ const owns =
270
+ data.subdomains?.some((s) => s.subdomain === subdomain) || false
271
+ if (process.env.DEBUG) {
272
+ log(`Owns site? ${owns}`)
244
273
  }
274
+ return owns
275
+ } catch (err) {
276
+ log(`Error checking ownership: ${err.message}`)
277
+ return false
278
+ } finally {
279
+ clear()
280
+ }
245
281
  }
246
282
 
247
283
  /**
248
284
  * Show upgrade prompt for anonymous users
249
285
  */
250
- function showUpgradePrompt() {
251
- log('');
252
- log('╔══════════════════════════════════════════════════════════════╗');
253
- log('║ Upgrade to Launchpd Free Tier ║');
254
- log('╠══════════════════════════════════════════════════════════════╣');
255
- log('║ Register for FREE to unlock: ║');
256
- log('║ → 10 sites (instead of 3) ║');
257
- log('║ → 100MB storage (instead of 50MB) ║');
258
- log('║ → 30-day retention (instead of 7 days) ║');
259
- log('║ → 10 version history per site ║');
260
- log('╠══════════════════════════════════════════════════════════════╣');
261
- log('║ Run: launchpd register ║');
262
- log('╚══════════════════════════════════════════════════════════════╝');
263
- log('');
286
+ function showUpgradePrompt () {
287
+ log('')
288
+ log('╔══════════════════════════════════════════════════════════════╗')
289
+ log('║ Upgrade to Launchpd Free Tier ║')
290
+ log('╠══════════════════════════════════════════════════════════════╣')
291
+ log('║ Register for FREE to unlock: ║')
292
+ log('║ → 10 sites (instead of 3) ║')
293
+ log('║ → 100MB storage (instead of 50MB) ║')
294
+ log('║ → 30-day retention (instead of 7 days) ║')
295
+ log('║ → 10 version history per site ║')
296
+ log('╠══════════════════════════════════════════════════════════════╣')
297
+ log('║ Run: launchpd register ║')
298
+ log('╚══════════════════════════════════════════════════════════════╝')
299
+ log('')
264
300
  }
265
301
 
266
302
  /**
267
303
  * Display quota warnings
268
304
  */
269
- export function displayQuotaWarnings(warnings) {
270
- if (warnings && warnings.length > 0) {
271
- log('');
272
- warnings.forEach(w => warning(w));
273
- }
305
+ export function displayQuotaWarnings (warnings) {
306
+ if (warnings && warnings.length > 0) {
307
+ log('')
308
+ warnings.forEach((w) => warning(w))
309
+ }
274
310
  }
275
311
 
276
312
  /**
277
313
  * Format bytes to human readable
278
314
  */
279
- export function formatBytes(bytes) {
280
- if (bytes === 0) return '0 B';
281
- const k = 1024;
282
- const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
283
- const i = Math.floor(Math.log(bytes) / Math.log(k));
284
- return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
315
+ export function formatBytes (bytes) {
316
+ if (bytes === 0) return '0 B'
317
+ const k = 1024
318
+ const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
319
+ const i = Math.floor(Math.log(bytes) / Math.log(k))
320
+ return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`
285
321
  }