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