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/LICENSE +21 -21
- package/README.md +61 -39
- package/bin/cli.js +149 -124
- package/bin/setup.js +42 -40
- package/package.json +4 -4
- package/src/commands/auth.js +516 -522
- package/src/commands/deploy.js +745 -386
- package/src/commands/index.js +14 -7
- package/src/commands/init.js +95 -72
- package/src/commands/list.js +120 -122
- package/src/commands/rollback.js +139 -102
- package/src/commands/status.js +75 -51
- package/src/commands/versions.js +153 -113
- package/src/config.js +32 -31
- package/src/utils/api.js +220 -195
- package/src/utils/credentials.js +88 -85
- package/src/utils/endpoint.js +58 -0
- package/src/utils/errors.js +79 -69
- package/src/utils/expiration.js +49 -47
- package/src/utils/id.js +5 -5
- package/src/utils/ignore.js +35 -36
- package/src/utils/index.js +10 -11
- package/src/utils/localConfig.js +39 -43
- package/src/utils/logger.js +113 -106
- package/src/utils/machineId.js +15 -19
- package/src/utils/metadata.js +113 -87
- package/src/utils/projectConfig.js +48 -45
- package/src/utils/prompt.js +91 -82
- package/src/utils/quota.js +261 -225
- package/src/utils/remoteSource.js +680 -0
- package/src/utils/upload.js +197 -127
- package/src/utils/validator.js +116 -68
package/src/utils/credentials.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
49
|
-
|
|
47
|
+
export async function getClientToken () {
|
|
48
|
+
await ensureConfigDir()
|
|
49
|
+
const tokenPath = getClientTokenPath()
|
|
50
50
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
//
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
89
|
-
// Corrupted or invalid JSON file
|
|
86
|
+
}
|
|
90
87
|
}
|
|
91
|
-
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
136
|
-
|
|
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
|
-
|
|
144
|
-
|
|
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
|
-
|
|
152
|
-
|
|
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
|
+
}
|
package/src/utils/errors.js
CHANGED
|
@@ -7,71 +7,74 @@
|
|
|
7
7
|
* Base API Error class
|
|
8
8
|
*/
|
|
9
9
|
export class APIError extends Error {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
86
|
+
export function handleCommonError (err, logger) {
|
|
87
|
+
const { error, info } = logger
|
|
85
88
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
}
|
|
127
|
+
APIError,
|
|
128
|
+
MaintenanceError,
|
|
129
|
+
AuthError,
|
|
130
|
+
QuotaError,
|
|
131
|
+
NetworkError,
|
|
132
|
+
TwoFactorRequiredError,
|
|
133
|
+
handleCommonError
|
|
134
|
+
}
|
package/src/utils/expiration.js
CHANGED
|
@@ -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
|
-
|
|
10
|
-
|
|
9
|
+
const regex = /^(\d+)([a-z]+)$/i
|
|
10
|
+
const match = regex.exec(timeStr)
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
12
|
+
if (!match) {
|
|
13
|
+
throw new Error(
|
|
14
|
+
`Invalid time format: "${timeStr}". Use format like 30m, 2h, 1d`
|
|
15
|
+
)
|
|
16
|
+
}
|
|
15
17
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
+
const value = Number.parseInt(match[1], 10)
|
|
19
|
+
const unit = match[2].toLowerCase()
|
|
18
20
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
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
|
-
|
|
49
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
60
|
+
const now = Date.now()
|
|
61
|
+
const expiry = new Date(expiresAt).getTime()
|
|
62
|
+
const remaining = expiry - now
|
|
61
63
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
64
|
+
if (remaining <= 0) {
|
|
65
|
+
return 'expired'
|
|
66
|
+
}
|
|
65
67
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
86
|
-
|
|
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
|
-
|
|
15
|
+
export function generateSubdomain () {
|
|
16
|
+
return nanoid()
|
|
17
17
|
}
|