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,151 +3,154 @@
3
3
  * Stores API key and user info in ~/.staticlaunch/credentials.json
4
4
  */
5
5
 
6
- import { existsSync } from 'node:fs';
7
- import { readFile, writeFile, mkdir, unlink } from 'node:fs/promises';
8
- import { join } from 'node:path';
9
- import { homedir } from 'node:os';
10
- import { randomBytes } from 'node:crypto';
6
+ import { existsSync } from 'node:fs'
7
+ import { readFile, writeFile, mkdir, unlink, chmod } from 'node:fs/promises'
8
+ import { join } from 'node:path'
9
+ import { homedir } from 'node:os'
10
+ import { randomBytes } from 'node:crypto'
11
11
 
12
12
  /**
13
13
  * Get the credentials directory path
14
14
  */
15
- function getConfigDir() {
16
- return join(homedir(), '.staticlaunch');
15
+ function getConfigDir () {
16
+ return join(homedir(), '.staticlaunch')
17
17
  }
18
18
 
19
19
  /**
20
20
  * Get the credentials file path
21
21
  */
22
- function getCredentialsPath() {
23
- return join(getConfigDir(), 'credentials.json');
22
+ function getCredentialsPath () {
23
+ return join(getConfigDir(), 'credentials.json')
24
24
  }
25
25
 
26
26
  /**
27
27
  * Get the client token path (for anonymous tracking)
28
28
  */
29
- function getClientTokenPath() {
30
- return join(getConfigDir(), 'client_token');
29
+ function getClientTokenPath () {
30
+ return join(getConfigDir(), 'client_token')
31
31
  }
32
32
 
33
33
  /**
34
34
  * Ensure config directory exists
35
35
  */
36
- async function ensureConfigDir() {
37
- const dir = getConfigDir();
38
- if (!existsSync(dir)) {
39
- await mkdir(dir, { recursive: true });
40
- }
36
+ async function ensureConfigDir () {
37
+ const dir = getConfigDir()
38
+ if (!existsSync(dir)) {
39
+ await mkdir(dir, { recursive: true })
40
+ }
41
41
  }
42
42
 
43
43
  /**
44
44
  * Get or create a persistent client token for anonymous tracking
45
45
  * This helps identify the same anonymous user across sessions
46
46
  */
47
- export async function getClientToken() {
48
- await ensureConfigDir();
49
- const tokenPath = getClientTokenPath();
47
+ export async function getClientToken () {
48
+ await ensureConfigDir()
49
+ const tokenPath = getClientTokenPath()
50
50
 
51
- try {
52
- if (existsSync(tokenPath)) {
53
- return await readFile(tokenPath, 'utf-8');
54
- }
55
- } catch {
56
- // Token file corrupted, regenerate
51
+ try {
52
+ if (existsSync(tokenPath)) {
53
+ return await readFile(tokenPath, 'utf-8')
57
54
  }
58
-
59
- // Generate new token
60
- const token = `cli_${randomBytes(16).toString('hex')}`;
61
- await writeFile(tokenPath, token, 'utf-8');
62
- return token;
55
+ } catch {
56
+ // Token file corrupted, regenerate
57
+ }
58
+
59
+ // Generate new token
60
+ const token = `cli_${randomBytes(16).toString('hex')}`
61
+ await writeFile(tokenPath, token, 'utf-8')
62
+ return token
63
63
  }
64
64
 
65
65
  /**
66
66
  * Get stored credentials
67
67
  * @returns {Promise<{apiKey: string, userId: string, email: string, tier: string} | null>}
68
68
  */
69
- export async function getCredentials() {
70
- const filePath = getCredentialsPath();
71
- try {
72
- if (existsSync(filePath)) {
73
- const text = await readFile(filePath, 'utf-8');
74
- const data = JSON.parse(text);
75
-
76
- // Validate the structure
77
- if (data.apiKey) {
78
- return {
79
- apiKey: data.apiKey,
80
- apiSecret: data.apiSecret || null,
81
- userId: data.userId || null,
82
- email: data.email || null,
83
- tier: data.tier || 'free',
84
- savedAt: data.savedAt || null,
85
- };
86
- }
69
+ export async function getCredentials () {
70
+ const filePath = getCredentialsPath()
71
+ try {
72
+ if (existsSync(filePath)) {
73
+ const text = await readFile(filePath, 'utf-8')
74
+ const data = JSON.parse(text)
75
+
76
+ // Validate the structure
77
+ if (data.apiKey) {
78
+ return {
79
+ apiKey: data.apiKey,
80
+ apiSecret: data.apiSecret || null,
81
+ userId: data.userId || null,
82
+ email: data.email || null,
83
+ tier: data.tier || 'free',
84
+ savedAt: data.savedAt || null
87
85
  }
88
- } catch {
89
- // Corrupted or invalid JSON file
86
+ }
90
87
  }
91
- return null;
88
+ } catch {
89
+ // Corrupted or invalid JSON file
90
+ }
91
+ return null
92
92
  }
93
93
 
94
94
  /**
95
95
  * Save credentials
96
96
  * @param {object} credentials - Credentials to save
97
97
  */
98
- export async function saveCredentials(credentials) {
99
- await ensureConfigDir();
100
-
101
- const data = {
102
- apiKey: credentials.apiKey,
103
- apiSecret: credentials.apiSecret || null,
104
- userId: credentials.userId || null,
105
- email: credentials.email || null,
106
- tier: credentials.tier || 'free',
107
- savedAt: new Date().toISOString(),
108
- };
109
-
110
- await writeFile(
111
- getCredentialsPath(),
112
- JSON.stringify(data, null, 2),
113
- 'utf-8'
114
- );
98
+ export async function saveCredentials (credentials) {
99
+ await ensureConfigDir()
100
+
101
+ const data = {
102
+ apiKey: credentials.apiKey,
103
+ apiSecret: credentials.apiSecret || null,
104
+ userId: credentials.userId || null,
105
+ email: credentials.email || null,
106
+ tier: credentials.tier || 'free',
107
+ savedAt: new Date().toISOString()
108
+ }
109
+
110
+ await writeFile(getCredentialsPath(), JSON.stringify(data, null, 2), 'utf-8')
111
+
112
+ // Restrict file permissions to owner only (0o600 = rw-------)
113
+ try {
114
+ await chmod(getCredentialsPath(), 0o600)
115
+ } catch {
116
+ // chmod may not be fully supported on Windows, ignore gracefully
117
+ }
115
118
  }
116
119
 
117
120
  /**
118
121
  * Delete stored credentials (logout)
119
122
  */
120
- export async function clearCredentials() {
121
- const filePath = getCredentialsPath();
122
- try {
123
- if (existsSync(filePath)) {
124
- await unlink(filePath);
125
- }
126
- } catch {
127
- // File doesn't exist or can't be deleted
123
+ export async function clearCredentials () {
124
+ const filePath = getCredentialsPath()
125
+ try {
126
+ if (existsSync(filePath)) {
127
+ await unlink(filePath)
128
128
  }
129
+ } catch {
130
+ // File doesn't exist or can't be deleted
131
+ }
129
132
  }
130
133
 
131
134
  /**
132
135
  * Check if user is logged in
133
136
  */
134
- export async function isLoggedIn() {
135
- const creds = await getCredentials();
136
- return creds !== null && creds.apiKey !== null;
137
+ export async function isLoggedIn () {
138
+ const creds = await getCredentials()
139
+ return creds !== null && creds.apiKey !== null
137
140
  }
138
141
 
139
142
  /**
140
143
  * Get the API key for requests (falls back to public beta key)
141
144
  */
142
- export async function getApiKey() {
143
- const creds = await getCredentials();
144
- return creds?.apiKey || process.env.STATICLAUNCH_API_KEY || 'public-beta-key';
145
+ export async function getApiKey () {
146
+ const creds = await getCredentials()
147
+ return creds?.apiKey || process.env.STATICLAUNCH_API_KEY || 'public-beta-key'
145
148
  }
146
149
 
147
150
  /**
148
151
  * Get the API secret for requests
149
152
  */
150
- export async function getApiSecret() {
151
- const creds = await getCredentials();
152
- return creds?.apiSecret || process.env.STATICLAUNCH_API_SECRET || null;
153
+ export async function getApiSecret () {
154
+ const creds = await getCredentials()
155
+ return creds?.apiSecret || process.env.STATICLAUNCH_API_SECRET || null
153
156
  }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Endpoint validation utility
3
+ */
4
+
5
+ import { APIError } from './errors.js'
6
+
7
+ /**
8
+ * Validates an endpoint to prevent SSRF vulnerabilities.
9
+ * It ensures the endpoint is a relative path and does not contain
10
+ * characters that could lead to path traversal or redirection to another host.
11
+ *
12
+ * @param {string} endpoint - The API endpoint to validate.
13
+ * @throws {APIError} If the endpoint is invalid.
14
+ */
15
+ export function validateEndpoint (endpoint) {
16
+ if (typeof endpoint !== 'string' || endpoint.trim() === '') {
17
+ throw new APIError('Endpoint must be a non-empty string.', 400)
18
+ }
19
+
20
+ // 0. Enforce relative path starting with slash
21
+ if (!endpoint.startsWith('/')) {
22
+ throw new APIError(
23
+ 'Endpoint must start with a slash (/), e.g. /api/deploy',
24
+ 400
25
+ )
26
+ }
27
+
28
+ // 1. Disallow absolute URLs
29
+ if (endpoint.startsWith('//') || endpoint.includes('://')) {
30
+ throw new APIError('Endpoint cannot be an absolute URL.', 400)
31
+ }
32
+
33
+ // 2. Prevent path traversal
34
+ if (endpoint.includes('..')) {
35
+ throw new APIError(
36
+ 'Endpoint cannot contain path traversal characters (..).',
37
+ 400
38
+ )
39
+ }
40
+
41
+ // 3. Check for characters that could be used for protocol or host manipulation
42
+ // Disallow characters like ':', '@', and '\' (encoded as %5C)
43
+ // We allow '/' for path segments.
44
+ // The regex checks for any characters that are not:
45
+ // - alphanumeric (a-z, A-Z, 0-9)
46
+ // - forward slash (/)
47
+ // - hyphen (-)
48
+ // - underscore (_)
49
+ // - dot (.)
50
+ // - question mark (?) for query params
51
+ // - equals (=) for query params
52
+ // - ampersand (&) for query params
53
+ // - percent (%) for url encoding
54
+ const allowedChars = /^[a-zA-Z0-9/\-_.?=&%]+$/
55
+ if (!allowedChars.test(endpoint)) {
56
+ throw new APIError('Endpoint contains invalid characters.', 400)
57
+ }
58
+ }
@@ -7,71 +7,74 @@
7
7
  * Base API Error class
8
8
  */
9
9
  export class APIError extends Error {
10
- constructor(message, statusCode = 500, data = {}) {
11
- super(message);
12
- this.name = 'APIError';
13
- this.statusCode = statusCode;
14
- this.data = data;
15
- this.isAPIError = true;
16
- }
10
+ constructor (message, statusCode = 500, data = {}) {
11
+ super(message)
12
+ this.name = 'APIError'
13
+ this.statusCode = statusCode
14
+ this.data = data
15
+ this.isAPIError = true
16
+ }
17
17
  }
18
18
 
19
19
  /**
20
20
  * Maintenance mode error - thrown when backend is under maintenance
21
21
  */
22
22
  export class MaintenanceError extends APIError {
23
- constructor(message = 'LaunchPd is under maintenance') {
24
- super(message, 503);
25
- this.name = 'MaintenanceError';
26
- this.isMaintenanceError = true;
27
- }
23
+ constructor (message = 'LaunchPd is under maintenance') {
24
+ super(message, 503)
25
+ this.name = 'MaintenanceError'
26
+ this.isMaintenanceError = true
27
+ }
28
28
  }
29
29
 
30
30
  /**
31
31
  * Authentication error - thrown for 401 responses
32
32
  */
33
33
  export class AuthError extends APIError {
34
- constructor(message = 'Authentication failed', data = {}) {
35
- super(message, 401, data);
36
- this.name = 'AuthError';
37
- this.isAuthError = true;
38
- this.requires2FA = data.requires_2fa || false;
39
- this.twoFactorType = data.two_factor_type || null;
40
- }
34
+ constructor (message = 'Authentication failed', data = {}) {
35
+ super(message, 401, data)
36
+ this.name = 'AuthError'
37
+ this.isAuthError = true
38
+ this.requires2FA = data.requires_2fa || false
39
+ this.twoFactorType = data.two_factor_type || null
40
+ }
41
41
  }
42
42
 
43
43
  /**
44
44
  * Quota error - thrown when user exceeds limits
45
45
  */
46
46
  export class QuotaError extends APIError {
47
- constructor(message = 'Quota exceeded', data = {}) {
48
- super(message, 429, data);
49
- this.name = 'QuotaError';
50
- this.isQuotaError = true;
51
- }
47
+ constructor (message = 'Quota exceeded', data = {}) {
48
+ super(message, 429, data)
49
+ this.name = 'QuotaError'
50
+ this.isQuotaError = true
51
+ }
52
52
  }
53
53
 
54
54
  /**
55
55
  * Network error - thrown for connection failures
56
56
  */
57
57
  export class NetworkError extends Error {
58
- constructor(message = 'Unable to connect to LaunchPd servers') {
59
- super(message);
60
- this.name = 'NetworkError';
61
- this.isNetworkError = true;
62
- }
58
+ constructor (message = 'Unable to connect to LaunchPd servers') {
59
+ super(message)
60
+ this.name = 'NetworkError'
61
+ this.isNetworkError = true
62
+ }
63
63
  }
64
64
 
65
65
  /**
66
66
  * Two-factor authentication required error
67
67
  */
68
68
  export class TwoFactorRequiredError extends APIError {
69
- constructor(twoFactorType = 'totp', message = 'Two-factor authentication required') {
70
- super(message, 200);
71
- this.name = 'TwoFactorRequiredError';
72
- this.isTwoFactorRequired = true;
73
- this.twoFactorType = twoFactorType;
74
- }
69
+ constructor (
70
+ twoFactorType = 'totp',
71
+ message = 'Two-factor authentication required'
72
+ ) {
73
+ super(message, 200)
74
+ this.name = 'TwoFactorRequiredError'
75
+ this.isTwoFactorRequired = true
76
+ this.twoFactorType = twoFactorType
77
+ }
75
78
  }
76
79
 
77
80
  /**
@@ -80,45 +83,52 @@ export class TwoFactorRequiredError extends APIError {
80
83
  * @param {object} logger - Logger with error, info, warning functions
81
84
  * @returns {boolean} - True if error was handled, false otherwise
82
85
  */
83
- export function handleCommonError(err, logger) {
84
- const { error, info } = logger;
86
+ export function handleCommonError (err, logger) {
87
+ const { error, info } = logger
85
88
 
86
- if (err instanceof MaintenanceError || err.isMaintenanceError) {
87
- error('⚠️ LaunchPd is under maintenance');
88
- info('Please try again in a few minutes');
89
- info('Check status at: https://status.launchpd.cloud');
90
- return true;
91
- }
89
+ if (err instanceof MaintenanceError || err.isMaintenanceError) {
90
+ error('⚠️ LaunchPd is under maintenance')
91
+ info('Please try again in a few minutes')
92
+ info('Check status at: https://status.launchpd.cloud')
93
+ return true
94
+ }
92
95
 
93
- if (err instanceof AuthError || err.isAuthError) {
94
- error('Authentication failed');
95
- info('Run "launchpd login" to authenticate');
96
- return true;
97
- }
96
+ if (err instanceof AuthError || err.isAuthError) {
97
+ error('Authentication failed')
98
+ info('Run "launchpd login" to authenticate')
99
+ return true
100
+ }
98
101
 
99
- if (err instanceof NetworkError || err.isNetworkError) {
100
- error('Unable to connect to LaunchPd');
101
- info('Check your internet connection');
102
- info('If the problem persists, check https://status.launchpd.cloud');
103
- return true;
104
- }
102
+ if (err instanceof NetworkError || err.isNetworkError) {
103
+ error('Unable to connect to LaunchPd')
104
+ info('Check your internet connection')
105
+ info('If the problem persists, check https://status.launchpd.cloud')
106
+ return true
107
+ }
105
108
 
106
- if (err instanceof QuotaError || err.isQuotaError) {
107
- error('Quota limit reached');
108
- info('Upgrade your plan or delete old deployments');
109
- info('Run "launchpd quota" to check your usage');
110
- return true;
111
- }
109
+ if (err.name === 'AbortError') {
110
+ error('Request timed out')
111
+ info('The server did not respond in time')
112
+ info('Check your internet connection or try again later')
113
+ return true
114
+ }
112
115
 
113
- return false;
116
+ if (err instanceof QuotaError || err.isQuotaError) {
117
+ error('Quota limit reached')
118
+ info('Upgrade your plan or delete old deployments')
119
+ info('Run "launchpd quota" to check your usage')
120
+ return true
121
+ }
122
+
123
+ return false
114
124
  }
115
125
 
116
126
  export default {
117
- APIError,
118
- MaintenanceError,
119
- AuthError,
120
- QuotaError,
121
- NetworkError,
122
- TwoFactorRequiredError,
123
- handleCommonError,
124
- };
127
+ APIError,
128
+ MaintenanceError,
129
+ AuthError,
130
+ QuotaError,
131
+ NetworkError,
132
+ TwoFactorRequiredError,
133
+ handleCommonError
134
+ }
@@ -4,39 +4,41 @@
4
4
  * Minimum: 30 minutes
5
5
  */
6
6
 
7
- export const MIN_EXPIRATION_MS = 30 * 60 * 1000;
7
+ export const MIN_EXPIRATION_MS = 30 * 60 * 1000
8
8
  export function parseTimeString(timeStr) {
9
- const regex = /^(\d+)([mhd])$/i;
10
- const match = regex.exec(timeStr);
9
+ const regex = /^(\d+)([a-z]+)$/i
10
+ const match = regex.exec(timeStr)
11
11
 
12
- if (!match) {
13
- throw new Error(`Invalid time format: "${timeStr}". Use format like 30m, 2h, 1d`);
14
- }
12
+ if (!match) {
13
+ throw new Error(
14
+ `Invalid time format: "${timeStr}". Use format like 30m, 2h, 1d`
15
+ )
16
+ }
15
17
 
16
- const value = Number.parseInt(match[1], 10);
17
- const unit = match[2].toLowerCase();
18
+ const value = Number.parseInt(match[1], 10)
19
+ const unit = match[2].toLowerCase()
18
20
 
19
- let ms;
20
- switch (unit) {
21
- case 'm':
22
- ms = value * 60 * 1000;
23
- break;
24
- case 'h':
25
- ms = value * 60 * 60 * 1000;
26
- break;
27
- case 'd':
28
- ms = value * 24 * 60 * 60 * 1000;
29
- break;
30
- default:
31
- throw new Error(`Unknown time unit: ${unit}`);
32
- }
21
+ let ms = 0
22
+ switch (unit) {
23
+ case 'm':
24
+ ms = value * 60 * 1000
25
+ break
26
+ case 'h':
27
+ ms = value * 60 * 60 * 1000
28
+ break
29
+ case 'd':
30
+ ms = value * 24 * 60 * 60 * 1000
31
+ break
32
+ default:
33
+ throw new Error(`Unknown time unit: ${unit}`)
34
+ }
33
35
 
34
- // Minimum 30 minutes
35
- if (ms < MIN_EXPIRATION_MS) {
36
- throw new Error('Minimum expiration time is 30 minutes (30m)');
37
- }
36
+ // Minimum 30 minutes
37
+ if (ms < MIN_EXPIRATION_MS) {
38
+ throw new Error('Minimum expiration time is 30 minutes (30m)')
39
+ }
38
40
 
39
- return ms;
41
+ return ms
40
42
  }
41
43
 
42
44
  /**
@@ -45,8 +47,8 @@ export function parseTimeString(timeStr) {
45
47
  * @returns {Date} Date object of expiration
46
48
  */
47
49
  export function calculateExpiresAt(timeStr) {
48
- const ms = parseTimeString(timeStr);
49
- return new Date(Date.now() + ms);
50
+ const ms = parseTimeString(timeStr)
51
+ return new Date(Date.now() + ms)
50
52
  }
51
53
 
52
54
  /**
@@ -55,25 +57,25 @@ export function calculateExpiresAt(timeStr) {
55
57
  * @returns {string} Human-readable time remaining
56
58
  */
57
59
  export function formatTimeRemaining(expiresAt) {
58
- const now = Date.now();
59
- const expiry = new Date(expiresAt).getTime();
60
- const remaining = expiry - now;
60
+ const now = Date.now()
61
+ const expiry = new Date(expiresAt).getTime()
62
+ const remaining = expiry - now
61
63
 
62
- if (remaining <= 0) {
63
- return 'expired';
64
- }
64
+ if (remaining <= 0) {
65
+ return 'expired'
66
+ }
65
67
 
66
- const minutes = Math.floor(remaining / (60 * 1000));
67
- const hours = Math.floor(remaining / (60 * 60 * 1000));
68
- const days = Math.floor(remaining / (24 * 60 * 60 * 1000));
68
+ const minutes = Math.floor(remaining / (60 * 1000))
69
+ const hours = Math.floor(remaining / (60 * 60 * 1000))
70
+ const days = Math.floor(remaining / (24 * 60 * 60 * 1000))
69
71
 
70
- if (days > 0) {
71
- return `${days}d ${hours % 24}h remaining`;
72
- } else if (hours > 0) {
73
- return `${hours}h ${minutes % 60}m remaining`;
74
- } else {
75
- return `${minutes}m remaining`;
76
- }
72
+ if (days > 0) {
73
+ return `${days}d ${hours % 24}h remaining`
74
+ } else if (hours > 0) {
75
+ return `${hours}h ${minutes % 60}m remaining`
76
+ } else {
77
+ return `${minutes}m remaining`
78
+ }
77
79
  }
78
80
 
79
81
  /**
@@ -82,6 +84,6 @@ export function formatTimeRemaining(expiresAt) {
82
84
  * @returns {boolean}
83
85
  */
84
86
  export function isExpired(expiresAt) {
85
- if (!expiresAt) return false;
86
- return new Date(expiresAt).getTime() < Date.now();
87
+ if (!expiresAt) return false
88
+ return new Date(expiresAt).getTime() < Date.now()
87
89
  }
package/src/utils/id.js CHANGED
@@ -1,17 +1,17 @@
1
- import { customAlphabet } from 'nanoid';
1
+ import { customAlphabet } from 'nanoid'
2
2
 
3
3
  /**
4
4
  * Generate a subdomain-safe unique ID
5
5
  * Uses lowercase alphanumeric characters only (valid for DNS)
6
6
  * 12 characters provides ~62 bits of entropy
7
7
  */
8
- const alphabet = 'abcdefghijklmnopqrstuvwxyz0123456789';
9
- const nanoid = customAlphabet(alphabet, 12);
8
+ const alphabet = 'abcdefghijklmnopqrstuvwxyz0123456789'
9
+ const nanoid = customAlphabet(alphabet, 12)
10
10
 
11
11
  /**
12
12
  * Generate a unique subdomain ID
13
13
  * @returns {string} A 12-character lowercase alphanumeric string
14
14
  */
15
- export function generateSubdomain() {
16
- return nanoid();
15
+ export function generateSubdomain () {
16
+ return nanoid()
17
17
  }