spck 0.3.1
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/.oxlintrc.json +49 -0
- package/LICENSE +21 -0
- package/README.md +631 -0
- package/bin/cli.js +20 -0
- package/bin/validate-cwd.js +41 -0
- package/dist/config/__tests__/config.test.d.ts +2 -0
- package/dist/config/__tests__/config.test.js +262 -0
- package/dist/config/__tests__/credentials.test.d.ts +2 -0
- package/dist/config/__tests__/credentials.test.js +360 -0
- package/dist/config/config.d.ts +33 -0
- package/dist/config/config.js +185 -0
- package/dist/config/credentials.d.ts +75 -0
- package/dist/config/credentials.js +259 -0
- package/dist/config/server-selection.d.ts +40 -0
- package/dist/config/server-selection.js +130 -0
- package/dist/connection/__tests__/firebase-auth.test.d.ts +2 -0
- package/dist/connection/__tests__/firebase-auth.test.js +96 -0
- package/dist/connection/__tests__/hmac.test.d.ts +2 -0
- package/dist/connection/__tests__/hmac.test.js +372 -0
- package/dist/connection/auth.d.ts +13 -0
- package/dist/connection/auth.js +91 -0
- package/dist/connection/firebase-auth.d.ts +40 -0
- package/dist/connection/firebase-auth.js +429 -0
- package/dist/connection/hmac.d.ts +24 -0
- package/dist/connection/hmac.js +109 -0
- package/dist/i18n/index.d.ts +25 -0
- package/dist/i18n/index.js +101 -0
- package/dist/i18n/locales/en.json +313 -0
- package/dist/i18n/locales/es.json +302 -0
- package/dist/i18n/locales/fr.json +302 -0
- package/dist/i18n/locales/id.json +302 -0
- package/dist/i18n/locales/ja.json +302 -0
- package/dist/i18n/locales/ko.json +302 -0
- package/dist/i18n/locales/locales/en.json +309 -0
- package/dist/i18n/locales/locales/es.json +302 -0
- package/dist/i18n/locales/locales/fr.json +302 -0
- package/dist/i18n/locales/locales/id.json +302 -0
- package/dist/i18n/locales/locales/ja.json +302 -0
- package/dist/i18n/locales/locales/ko.json +302 -0
- package/dist/i18n/locales/locales/pt.json +302 -0
- package/dist/i18n/locales/locales/zh-Hans.json +302 -0
- package/dist/i18n/locales/pt.json +302 -0
- package/dist/i18n/locales/zh-Hans.json +302 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.js +493 -0
- package/dist/proxy/ProxyClient.d.ts +125 -0
- package/dist/proxy/ProxyClient.js +781 -0
- package/dist/proxy/ProxySocketWrapper.d.ts +43 -0
- package/dist/proxy/ProxySocketWrapper.js +98 -0
- package/dist/proxy/__tests__/ProxyClient.test.d.ts +2 -0
- package/dist/proxy/__tests__/ProxyClient.test.js +445 -0
- package/dist/proxy/__tests__/ProxySocketWrapper.test.d.ts +2 -0
- package/dist/proxy/__tests__/ProxySocketWrapper.test.js +190 -0
- package/dist/proxy/__tests__/handshake-validation.test.d.ts +2 -0
- package/dist/proxy/__tests__/handshake-validation.test.js +282 -0
- package/dist/proxy/__tests__/token-refresh-race.test.d.ts +14 -0
- package/dist/proxy/__tests__/token-refresh-race.test.js +173 -0
- package/dist/proxy/chunking.d.ts +53 -0
- package/dist/proxy/chunking.js +127 -0
- package/dist/proxy/handshake-validation.d.ts +21 -0
- package/dist/proxy/handshake-validation.js +49 -0
- package/dist/rpc/__tests__/router.test.d.ts +2 -0
- package/dist/rpc/__tests__/router.test.js +262 -0
- package/dist/rpc/router.d.ts +37 -0
- package/dist/rpc/router.js +132 -0
- package/dist/services/BrowserProxyService.d.ts +13 -0
- package/dist/services/BrowserProxyService.js +139 -0
- package/dist/services/FilesystemService.d.ts +99 -0
- package/dist/services/FilesystemService.js +742 -0
- package/dist/services/GitService.d.ts +243 -0
- package/dist/services/GitService.js +1439 -0
- package/dist/services/SearchService.d.ts +93 -0
- package/dist/services/SearchService.js +670 -0
- package/dist/services/TerminalService.d.ts +62 -0
- package/dist/services/TerminalService.js +337 -0
- package/dist/services/__tests__/BrowserProxyService.test.d.ts +2 -0
- package/dist/services/__tests__/BrowserProxyService.test.js +145 -0
- package/dist/services/__tests__/FilesystemService.test.d.ts +2 -0
- package/dist/services/__tests__/FilesystemService.test.js +609 -0
- package/dist/services/__tests__/GitService.test.d.ts +2 -0
- package/dist/services/__tests__/GitService.test.js +953 -0
- package/dist/services/__tests__/SearchService.test.d.ts +2 -0
- package/dist/services/__tests__/SearchService.test.js +384 -0
- package/dist/services/__tests__/TerminalService.test.d.ts +2 -0
- package/dist/services/__tests__/TerminalService.test.js +513 -0
- package/dist/setup/wizard.d.ts +10 -0
- package/dist/setup/wizard.js +172 -0
- package/dist/types.d.ts +196 -0
- package/dist/types.js +44 -0
- package/dist/utils/__tests__/gitignore.test.d.ts +2 -0
- package/dist/utils/__tests__/gitignore.test.js +127 -0
- package/dist/utils/gitignore.d.ts +24 -0
- package/dist/utils/gitignore.js +77 -0
- package/dist/utils/logger.d.ts +96 -0
- package/dist/utils/logger.js +456 -0
- package/dist/utils/project-dir.d.ts +51 -0
- package/dist/utils/project-dir.js +191 -0
- package/dist/utils/ripgrep.d.ts +34 -0
- package/dist/utils/ripgrep.js +148 -0
- package/dist/utils/tool-detection.d.ts +17 -0
- package/dist/utils/tool-detection.js +126 -0
- package/dist/watcher/FileWatcher.d.ts +10 -0
- package/dist/watcher/FileWatcher.js +42 -0
- package/package.json +70 -0
- package/src/config/__tests__/config.test.ts +318 -0
- package/src/config/__tests__/credentials.test.ts +494 -0
- package/src/config/config.ts +206 -0
- package/src/config/credentials.ts +302 -0
- package/src/config/server-selection.ts +150 -0
- package/src/connection/__tests__/firebase-auth.test.ts +121 -0
- package/src/connection/__tests__/hmac.test.ts +509 -0
- package/src/connection/auth.ts +140 -0
- package/src/connection/firebase-auth.ts +504 -0
- package/src/connection/hmac.ts +139 -0
- package/src/i18n/index.ts +119 -0
- package/src/i18n/locales/en.json +313 -0
- package/src/i18n/locales/es.json +302 -0
- package/src/i18n/locales/fr.json +302 -0
- package/src/i18n/locales/id.json +302 -0
- package/src/i18n/locales/ja.json +302 -0
- package/src/i18n/locales/ko.json +302 -0
- package/src/i18n/locales/pt.json +302 -0
- package/src/i18n/locales/zh-Hans.json +302 -0
- package/src/index.ts +542 -0
- package/src/proxy/ProxyClient.ts +968 -0
- package/src/proxy/ProxySocketWrapper.ts +113 -0
- package/src/proxy/__tests__/ProxyClient.test.ts +575 -0
- package/src/proxy/__tests__/ProxySocketWrapper.test.ts +251 -0
- package/src/proxy/__tests__/handshake-validation.test.ts +367 -0
- package/src/proxy/chunking.ts +162 -0
- package/src/proxy/handshake-validation.ts +64 -0
- package/src/rpc/__tests__/router.test.ts +400 -0
- package/src/rpc/router.ts +183 -0
- package/src/services/BrowserProxyService.ts +179 -0
- package/src/services/FilesystemService.ts +841 -0
- package/src/services/GitService.ts +1639 -0
- package/src/services/SearchService.ts +809 -0
- package/src/services/TerminalService.ts +413 -0
- package/src/services/__tests__/BrowserProxyService.test.ts +155 -0
- package/src/services/__tests__/FilesystemService.test.ts +1002 -0
- package/src/services/__tests__/GitService.test.ts +1552 -0
- package/src/services/__tests__/SearchService.test.ts +484 -0
- package/src/services/__tests__/TerminalService.test.ts +702 -0
- package/src/setup/wizard.ts +242 -0
- package/src/types/fossil-delta.d.ts +4 -0
- package/src/types.ts +287 -0
- package/src/utils/__tests__/gitignore.test.ts +174 -0
- package/src/utils/gitignore.ts +91 -0
- package/src/utils/logger.ts +578 -0
- package/src/utils/project-dir.ts +218 -0
- package/src/utils/ripgrep.ts +180 -0
- package/src/utils/tool-detection.ts +141 -0
- package/src/watcher/FileWatcher.ts +53 -0
- package/tsconfig.json +24 -0
- package/vitest.config.ts +19 -0
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Firebase credentials management
|
|
3
|
+
* Handles user-level credential storage in ~/.spck-editor/.credentials.json
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import * as fs from 'fs';
|
|
7
|
+
import * as path from 'path';
|
|
8
|
+
import * as os from 'os';
|
|
9
|
+
import { StoredCredentials, GlobalConfig } from '../types.js';
|
|
10
|
+
import { getProjectFilePath } from '../utils/project-dir.js';
|
|
11
|
+
import { logAuth } from '../utils/logger.js';
|
|
12
|
+
import { t } from '../i18n/index.js';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Get the user-level credentials directory
|
|
16
|
+
*/
|
|
17
|
+
export function getCredentialsDir(): string {
|
|
18
|
+
return path.join(os.homedir(), '.spck-editor');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Get the credentials file path
|
|
23
|
+
*/
|
|
24
|
+
export function getCredentialsPath(): string {
|
|
25
|
+
return path.join(getCredentialsDir(), '.credentials.json');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Get the global config file path
|
|
30
|
+
*/
|
|
31
|
+
export function getGlobalConfigPath(): string {
|
|
32
|
+
return path.join(getCredentialsDir(), 'global.config');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Load global config from user-level storage
|
|
37
|
+
*/
|
|
38
|
+
export function loadGlobalConfig(): GlobalConfig {
|
|
39
|
+
const configPath = getGlobalConfigPath();
|
|
40
|
+
|
|
41
|
+
if (!fs.existsSync(configPath)) {
|
|
42
|
+
return { knownDeviceIds: [] };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
const data = fs.readFileSync(configPath, 'utf8');
|
|
47
|
+
const parsed = JSON.parse(data);
|
|
48
|
+
return {
|
|
49
|
+
knownDeviceIds: Array.isArray(parsed.knownDeviceIds) ? parsed.knownDeviceIds : [],
|
|
50
|
+
};
|
|
51
|
+
} catch {
|
|
52
|
+
return { knownDeviceIds: [] };
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Save global config to user-level storage
|
|
58
|
+
*/
|
|
59
|
+
export function saveGlobalConfig(config: GlobalConfig): void {
|
|
60
|
+
const credentialsDir = getCredentialsDir();
|
|
61
|
+
const configPath = getGlobalConfigPath();
|
|
62
|
+
|
|
63
|
+
if (!fs.existsSync(credentialsDir)) {
|
|
64
|
+
fs.mkdirSync(credentialsDir, { recursive: true, mode: 0o700 });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
fs.writeFileSync(
|
|
68
|
+
configPath,
|
|
69
|
+
JSON.stringify(config, null, 2),
|
|
70
|
+
{ encoding: 'utf8', mode: 0o600 }
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Get the connection settings file path (project-level)
|
|
76
|
+
* This uses the symlinked project directory
|
|
77
|
+
*/
|
|
78
|
+
export function getConnectionSettingsPath(): string {
|
|
79
|
+
return getProjectFilePath(process.cwd(), 'connection-settings.json');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Load stored credentials from user-level storage
|
|
84
|
+
* Returns only refreshToken + userId; firebaseToken is generated on demand
|
|
85
|
+
* @throws {Error} with code 'CORRUPTED' if file is corrupted
|
|
86
|
+
*/
|
|
87
|
+
export function loadCredentials(): StoredCredentials | null {
|
|
88
|
+
const credentialsPath = getCredentialsPath();
|
|
89
|
+
|
|
90
|
+
if (!fs.existsSync(credentialsPath)) {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
const data = fs.readFileSync(credentialsPath, 'utf8');
|
|
96
|
+
const credentials = JSON.parse(data);
|
|
97
|
+
|
|
98
|
+
// Validate required fields for stored credentials
|
|
99
|
+
if (!credentials.refreshToken || !credentials.userId) {
|
|
100
|
+
const error: any = new Error('Invalid credentials format - missing refreshToken or userId');
|
|
101
|
+
error.code = 'CORRUPTED';
|
|
102
|
+
error.path = credentialsPath;
|
|
103
|
+
throw error;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Return only the stored fields (refreshToken + userId + optional proxyServerUrl)
|
|
107
|
+
const result: StoredCredentials = {
|
|
108
|
+
refreshToken: credentials.refreshToken,
|
|
109
|
+
userId: credentials.userId,
|
|
110
|
+
proxyServerUrl: credentials.proxyServerUrl
|
|
111
|
+
};
|
|
112
|
+
return result;
|
|
113
|
+
} catch (error: any) {
|
|
114
|
+
// JSON parse error or validation error
|
|
115
|
+
if (error instanceof SyntaxError || error.code === 'CORRUPTED') {
|
|
116
|
+
logAuth('credentials_corrupted', {
|
|
117
|
+
path: credentialsPath,
|
|
118
|
+
error: error.message
|
|
119
|
+
}, 'error');
|
|
120
|
+
console.warn(`⚠️ ${t('credentials.corrupted', { path: credentialsPath })}`);
|
|
121
|
+
console.warn(` ${t('credentials.corruptedHint')}\n`);
|
|
122
|
+
const corruptedError: any = new Error('Credentials file is corrupted');
|
|
123
|
+
corruptedError.code = 'CORRUPTED';
|
|
124
|
+
corruptedError.path = credentialsPath;
|
|
125
|
+
corruptedError.originalError = error;
|
|
126
|
+
throw corruptedError;
|
|
127
|
+
}
|
|
128
|
+
// Other errors (permission, etc.)
|
|
129
|
+
throw error;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Save stored credentials to user-level storage
|
|
135
|
+
* Only persists refreshToken + userId (not firebaseToken or expiry)
|
|
136
|
+
* @throws {Error} with code 'EACCES' for permission errors
|
|
137
|
+
* @throws {Error} with code 'ENOSPC' for disk full errors
|
|
138
|
+
*/
|
|
139
|
+
export function saveCredentials(credentials: StoredCredentials): void {
|
|
140
|
+
const credentialsDir = getCredentialsDir();
|
|
141
|
+
const credentialsPath = getCredentialsPath();
|
|
142
|
+
|
|
143
|
+
try {
|
|
144
|
+
// Ensure directory exists
|
|
145
|
+
if (!fs.existsSync(credentialsDir)) {
|
|
146
|
+
fs.mkdirSync(credentialsDir, { recursive: true, mode: 0o700 });
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Persist refreshToken + userId + optional proxyServerUrl
|
|
150
|
+
const storedData: StoredCredentials = {
|
|
151
|
+
refreshToken: credentials.refreshToken,
|
|
152
|
+
userId: credentials.userId
|
|
153
|
+
};
|
|
154
|
+
if (credentials.proxyServerUrl) {
|
|
155
|
+
storedData.proxyServerUrl = credentials.proxyServerUrl;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Write credentials file with restricted permissions
|
|
159
|
+
fs.writeFileSync(
|
|
160
|
+
credentialsPath,
|
|
161
|
+
JSON.stringify(storedData, null, 2),
|
|
162
|
+
{ encoding: 'utf8', mode: 0o600 }
|
|
163
|
+
);
|
|
164
|
+
} catch (error: any) {
|
|
165
|
+
// Add context to error
|
|
166
|
+
error.path = error.path || credentialsPath;
|
|
167
|
+
error.operation = 'save credentials';
|
|
168
|
+
throw error;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Clear credentials (logout)
|
|
175
|
+
*/
|
|
176
|
+
export function clearCredentials(): void {
|
|
177
|
+
const credentialsPath = getCredentialsPath();
|
|
178
|
+
|
|
179
|
+
if (fs.existsSync(credentialsPath)) {
|
|
180
|
+
fs.unlinkSync(credentialsPath);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Load connection settings from project-level storage
|
|
186
|
+
* @throws {Error} with code 'CORRUPTED' if file is corrupted
|
|
187
|
+
*/
|
|
188
|
+
export function loadConnectionSettings(): any | null {
|
|
189
|
+
const settingsPath = getConnectionSettingsPath();
|
|
190
|
+
|
|
191
|
+
if (!fs.existsSync(settingsPath)) {
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
try {
|
|
196
|
+
const data = fs.readFileSync(settingsPath, 'utf8');
|
|
197
|
+
const settings = JSON.parse(data);
|
|
198
|
+
|
|
199
|
+
// Validate basic structure
|
|
200
|
+
if (!settings.serverToken || !settings.clientId || !settings.secret) {
|
|
201
|
+
const error: any = new Error('Invalid connection settings format - missing required fields');
|
|
202
|
+
error.code = 'CORRUPTED';
|
|
203
|
+
error.path = settingsPath;
|
|
204
|
+
throw error;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return settings;
|
|
208
|
+
} catch (error: any) {
|
|
209
|
+
// JSON parse error or validation error
|
|
210
|
+
if (error instanceof SyntaxError || error.code === 'CORRUPTED') {
|
|
211
|
+
logAuth('connection_settings_corrupted', {
|
|
212
|
+
path: settingsPath,
|
|
213
|
+
error: error.message
|
|
214
|
+
}, 'error');
|
|
215
|
+
console.warn(`⚠️ ${t('credentials.settingsCorrupted', { path: settingsPath })}`);
|
|
216
|
+
console.warn(` ${t('credentials.settingsCorruptedHint')}\n`);
|
|
217
|
+
const corruptedError: any = new Error('Connection settings file is corrupted');
|
|
218
|
+
corruptedError.code = 'CORRUPTED';
|
|
219
|
+
corruptedError.path = settingsPath;
|
|
220
|
+
corruptedError.originalError = error;
|
|
221
|
+
throw corruptedError;
|
|
222
|
+
}
|
|
223
|
+
// Other errors (permission, etc.)
|
|
224
|
+
throw error;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Save connection settings to project-level storage
|
|
230
|
+
* @throws {Error} with code 'EACCES' for permission errors
|
|
231
|
+
* @throws {Error} with code 'ENOSPC' for disk full errors
|
|
232
|
+
*/
|
|
233
|
+
export function saveConnectionSettings(settings: any): void {
|
|
234
|
+
const settingsDir = path.dirname(getConnectionSettingsPath());
|
|
235
|
+
const settingsPath = getConnectionSettingsPath();
|
|
236
|
+
|
|
237
|
+
try {
|
|
238
|
+
// Ensure directory exists with restricted permissions
|
|
239
|
+
if (!fs.existsSync(settingsDir)) {
|
|
240
|
+
fs.mkdirSync(settingsDir, { recursive: true, mode: 0o700 });
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Write settings file with restricted permissions (owner read/write only)
|
|
244
|
+
fs.writeFileSync(
|
|
245
|
+
settingsPath,
|
|
246
|
+
JSON.stringify(settings, null, 2),
|
|
247
|
+
{ encoding: 'utf8', mode: 0o600 }
|
|
248
|
+
);
|
|
249
|
+
} catch (error: any) {
|
|
250
|
+
// Add context to error
|
|
251
|
+
error.path = error.path || settingsPath;
|
|
252
|
+
error.operation = 'save connection settings';
|
|
253
|
+
throw error;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Clear connection settings
|
|
259
|
+
*/
|
|
260
|
+
export function clearConnectionSettings(): void {
|
|
261
|
+
const settingsPath = getConnectionSettingsPath();
|
|
262
|
+
|
|
263
|
+
if (fs.existsSync(settingsPath)) {
|
|
264
|
+
fs.unlinkSync(settingsPath);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Load saved proxy server preference from user-level credentials
|
|
270
|
+
*/
|
|
271
|
+
export function loadServerPreference(): string | null {
|
|
272
|
+
try {
|
|
273
|
+
const credentials = loadCredentials();
|
|
274
|
+
return credentials?.proxyServerUrl || null;
|
|
275
|
+
} catch {
|
|
276
|
+
return null;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Save proxy server preference to user-level credentials
|
|
282
|
+
*/
|
|
283
|
+
export function saveServerPreference(proxyServerUrl: string): void {
|
|
284
|
+
const credentials = loadCredentials();
|
|
285
|
+
if (credentials) {
|
|
286
|
+
credentials.proxyServerUrl = proxyServerUrl;
|
|
287
|
+
saveCredentials(credentials);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Check if server JWT is expired
|
|
293
|
+
*/
|
|
294
|
+
export function isServerTokenExpired(settings: any): boolean {
|
|
295
|
+
if (!settings || !settings.serverTokenExpiry) {
|
|
296
|
+
return true;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Add 5-minute buffer for safety
|
|
300
|
+
const expiryWithBuffer = settings.serverTokenExpiry - (5 * 60 * 1000);
|
|
301
|
+
return Date.now() > expiryWithBuffer;
|
|
302
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI Server Selection
|
|
3
|
+
* Fetches available relay servers, checks ping, and auto-selects the best one
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { t } from '../i18n/index.js';
|
|
7
|
+
import { getLocale } from '../i18n/index.js';
|
|
8
|
+
|
|
9
|
+
export interface CLIServer {
|
|
10
|
+
label: Record<string, string>;
|
|
11
|
+
url: string;
|
|
12
|
+
locale: Record<string, number>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Hardcoded default server list — used as fallback when no server responds
|
|
17
|
+
*/
|
|
18
|
+
const DEFAULT_SERVERS: CLIServer[] = [
|
|
19
|
+
{
|
|
20
|
+
label: {en: 'Europe', es: 'Europa', fr: 'Europe', de: 'Europa', pt: 'Europa', ru: '\u0415\u0432\u0440\u043e\u043f\u0430', ja: '\u30e8\u30fc\u30ed\u30c3\u30d1', ko: '\uc720\ub7fd', zh: '\u6b27\u6d32', zhTW: '\u6b50\u6d32', id: 'Eropa'},
|
|
21
|
+
url: 'cli-eu-1.spck.io',
|
|
22
|
+
locale: {en: 3, es: 2, fr: 1, de: 1, pt: 2, ru: 1, ja: 3, ko: 3, zh: 3, zhTW: 3, id: 3}
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
label: {en: 'North America', es: 'Am\u00e9rica del Norte', fr: 'Am\u00e9rique du Nord', de: 'Nordamerika', pt: 'Am\u00e9rica do Norte', ru: '\u0421\u0435\u0432\u0435\u0440\u043d\u0430\u044f \u0410\u043c\u0435\u0440\u0438\u043a\u0430', ja: '\u5317\u30a2\u30e1\u30ea\u30ab', ko: '\ubd81\uc544\uba54\ub9ac\uce74', zh: '\u5317\u7f8e\u6d32', zhTW: '\u5317\u7f8e\u6d32', id: 'Amerika Utara'},
|
|
26
|
+
url: 'cli-na-1.spck.io',
|
|
27
|
+
locale: {en: 1, es: 1, fr: 2, de: 2, pt: 1, ru: 2, ja: 4, ko: 4, zh: 4, zhTW: 4, id: 4}
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
label: {en: 'South Asia', es: 'Asia del Sur', fr: 'Asie du Sud', de: 'S\u00fcdasien', pt: '\u00c1sia Meridional', ru: '\u042e\u0436\u043d\u0430\u044f \u0410\u0437\u0438\u044f', ja: '\u5357\u30a2\u30b8\u30a2', ko: '\ub0a8\uc544\uc2dc\uc544', zh: '\u5357\u4e9a', zhTW: '\u5357\u4e9e', id: 'Asia Selatan'},
|
|
31
|
+
url: 'cli-sas-1.spck.io',
|
|
32
|
+
locale: {en: 2, es: 4, fr: 4, de: 4, pt: 4, ru: 4, ja: 2, ko: 2, zh: 2, zhTW: 2, id: 1}
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
label: {en: 'East Asia', es: 'Asia Oriental', fr: 'Asie de l\u2019Est', de: 'Ostasien', pt: '\u00c1sia Oriental', ru: '\u0412\u043e\u0441\u0442\u043e\u0447\u043d\u0430\u044f \u0410\u0437\u0438\u044f', ja: '\u6771\u30a2\u30b8\u30a2', ko: '\ub3d9\uc544\uc2dc\uc544', zh: '\u4e1c\u4e9a', zhTW: '\u6771\u4e9e', id: 'Asia Timur'},
|
|
36
|
+
url: 'cli-ea-1.spck.io',
|
|
37
|
+
locale: {en: 4, es: 3, fr: 3, de: 3, pt: 3, ru: 3, ja: 1, ko: 1, zh: 1, zhTW: 1, id: 2}
|
|
38
|
+
}
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Validate that a server URL belongs to spck.io (any subdomain).
|
|
43
|
+
* Rejects bare IPs, other domains, and URLs with protocols.
|
|
44
|
+
*/
|
|
45
|
+
export function isValidDomain(url: string): boolean {
|
|
46
|
+
return /^([a-zA-Z0-9-]+\.)*spck\.io$/.test(url);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Get the hardcoded default server list
|
|
51
|
+
*/
|
|
52
|
+
export function getDefaultServerList(): CLIServer[] {
|
|
53
|
+
return DEFAULT_SERVERS;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Fetch the list of available CLI servers from the closest relay server.
|
|
58
|
+
* Tries servers sorted by locale proximity, falls back to hardcoded list.
|
|
59
|
+
*/
|
|
60
|
+
export async function fetchServerList(): Promise<CLIServer[]> {
|
|
61
|
+
// Race all servers in parallel — first successful response wins
|
|
62
|
+
try {
|
|
63
|
+
return await Promise.any(
|
|
64
|
+
DEFAULT_SERVERS.map(async (server) => {
|
|
65
|
+
const response = await fetch(`https://${server.url}/servers`, {
|
|
66
|
+
signal: AbortSignal.timeout(5000),
|
|
67
|
+
});
|
|
68
|
+
if (!response.ok) throw new Error(`${response.status}`);
|
|
69
|
+
const list = await response.json() as CLIServer[];
|
|
70
|
+
// Reject any server whose URL is not a *.spck.io domain
|
|
71
|
+
return list.filter(s => isValidDomain(s.url));
|
|
72
|
+
})
|
|
73
|
+
);
|
|
74
|
+
} catch {
|
|
75
|
+
// All servers failed — fall back to hardcoded list
|
|
76
|
+
return DEFAULT_SERVERS;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Check ping to a server by making 4 parallel HTTP calls to /health
|
|
82
|
+
* and averaging the latency
|
|
83
|
+
*/
|
|
84
|
+
export async function checkServerPing(serverUrl: string): Promise<number> {
|
|
85
|
+
const shortestTime = await Promise.any(
|
|
86
|
+
Array.from({ length: 4 }, async () => {
|
|
87
|
+
const start = Date.now();
|
|
88
|
+
try {
|
|
89
|
+
const response = await fetch(`https://${serverUrl}/health`, {
|
|
90
|
+
signal: AbortSignal.timeout(5000),
|
|
91
|
+
});
|
|
92
|
+
if (!response.ok) return Infinity;
|
|
93
|
+
} catch {
|
|
94
|
+
return Infinity;
|
|
95
|
+
}
|
|
96
|
+
return Date.now() - start;
|
|
97
|
+
})
|
|
98
|
+
);
|
|
99
|
+
return Math.round(shortestTime);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Ping all servers in parallel and select the one with the lowest latency
|
|
104
|
+
*/
|
|
105
|
+
export async function selectBestServer(
|
|
106
|
+
servers: CLIServer[]
|
|
107
|
+
): Promise<{ server: CLIServer; ping: number }> {
|
|
108
|
+
const results = await Promise.all(
|
|
109
|
+
servers.map(async (server) => {
|
|
110
|
+
try {
|
|
111
|
+
const ping = await checkServerPing(server.url);
|
|
112
|
+
return { server, ping };
|
|
113
|
+
} catch {
|
|
114
|
+
return { server, ping: Infinity };
|
|
115
|
+
}
|
|
116
|
+
})
|
|
117
|
+
);
|
|
118
|
+
results.sort((a, b) => a.ping - b.ping);
|
|
119
|
+
return results[0];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Display ping results for all servers
|
|
124
|
+
*/
|
|
125
|
+
export async function displayServerPings(servers: CLIServer[]): Promise<Map<string, number>> {
|
|
126
|
+
console.log('\n ' + t('server.checkingLatency') + '\n');
|
|
127
|
+
|
|
128
|
+
const pingResults = new Map<string, number>();
|
|
129
|
+
const results = await Promise.all(
|
|
130
|
+
servers.map(async (server) => {
|
|
131
|
+
try {
|
|
132
|
+
const ping = await checkServerPing(server.url);
|
|
133
|
+
return { server, ping };
|
|
134
|
+
} catch {
|
|
135
|
+
return { server, ping: Infinity };
|
|
136
|
+
}
|
|
137
|
+
})
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
const locale = getLocale();
|
|
141
|
+
for (const { server, ping } of results) {
|
|
142
|
+
const label = server.label[locale] || server.label.en || server.url;
|
|
143
|
+
const pingStr = ping === Infinity ? t('server.unreachable') : `${ping}ms`;
|
|
144
|
+
console.log(` ${label}: ${pingStr}`);
|
|
145
|
+
pingResults.set(server.url, ping);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
console.log('');
|
|
149
|
+
return pingResults;
|
|
150
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
2
|
+
/**
|
|
3
|
+
* Tests for Firebase auth token refresh logic
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { StoredCredentials } from '../../types.js';
|
|
7
|
+
|
|
8
|
+
// Mock all external dependencies before importing the module
|
|
9
|
+
vi.mock('open', () => ({ default: vi.fn() }));
|
|
10
|
+
vi.mock('qrcode-terminal', () => ({ default: { generate: vi.fn() } }));
|
|
11
|
+
vi.mock('jsonwebtoken', () => ({ default: { decode: vi.fn() } }));
|
|
12
|
+
vi.mock('../../config/credentials.js', () => ({
|
|
13
|
+
saveCredentials: vi.fn(),
|
|
14
|
+
}));
|
|
15
|
+
vi.mock('../../utils/logger.js', () => ({
|
|
16
|
+
logAuth: vi.fn(),
|
|
17
|
+
}));
|
|
18
|
+
vi.mock('../../i18n/index.js', () => ({
|
|
19
|
+
t: (key: string) => key,
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
// Import after mocks are set up
|
|
23
|
+
import { refreshFirebaseToken } from '../firebase-auth.js';
|
|
24
|
+
|
|
25
|
+
describe('refreshFirebaseToken()', () => {
|
|
26
|
+
const mockStoredCredentials: StoredCredentials = {
|
|
27
|
+
refreshToken: 'mock-refresh-token',
|
|
28
|
+
userId: 'user-123',
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
beforeEach(() => {
|
|
32
|
+
vi.clearAllMocks();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
afterEach(() => {
|
|
36
|
+
vi.restoreAllMocks();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('should use expires_in value of 0 without falling back to default', async () => {
|
|
40
|
+
const now = Date.now();
|
|
41
|
+
vi.spyOn(Date, 'now').mockReturnValue(now);
|
|
42
|
+
|
|
43
|
+
// Firebase returns expires_in: "0"
|
|
44
|
+
const mockResponse = {
|
|
45
|
+
ok: true,
|
|
46
|
+
json: vi.fn().mockResolvedValue({
|
|
47
|
+
id_token: 'new-id-token',
|
|
48
|
+
refresh_token: 'new-refresh-token',
|
|
49
|
+
expires_in: '0',
|
|
50
|
+
user_id: 'user-123',
|
|
51
|
+
}),
|
|
52
|
+
};
|
|
53
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse));
|
|
54
|
+
|
|
55
|
+
const result = await refreshFirebaseToken(mockStoredCredentials);
|
|
56
|
+
|
|
57
|
+
// With the bug (|| 3600), this would be now + 3600000
|
|
58
|
+
// With the fix (isNaN check), expires_in=0 means token expires immediately
|
|
59
|
+
expect(result.firebaseTokenExpiry).toBe(now);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('should use the actual expires_in from the response', async () => {
|
|
63
|
+
const now = Date.now();
|
|
64
|
+
vi.spyOn(Date, 'now').mockReturnValue(now);
|
|
65
|
+
|
|
66
|
+
const mockResponse = {
|
|
67
|
+
ok: true,
|
|
68
|
+
json: vi.fn().mockResolvedValue({
|
|
69
|
+
id_token: 'new-id-token',
|
|
70
|
+
refresh_token: 'new-refresh-token',
|
|
71
|
+
expires_in: '7200',
|
|
72
|
+
user_id: 'user-123',
|
|
73
|
+
}),
|
|
74
|
+
};
|
|
75
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse));
|
|
76
|
+
|
|
77
|
+
const result = await refreshFirebaseToken(mockStoredCredentials);
|
|
78
|
+
|
|
79
|
+
expect(result.firebaseTokenExpiry).toBe(now + 7200 * 1000);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('should default to 3600 when expires_in is missing', async () => {
|
|
83
|
+
const now = Date.now();
|
|
84
|
+
vi.spyOn(Date, 'now').mockReturnValue(now);
|
|
85
|
+
|
|
86
|
+
const mockResponse = {
|
|
87
|
+
ok: true,
|
|
88
|
+
json: vi.fn().mockResolvedValue({
|
|
89
|
+
id_token: 'new-id-token',
|
|
90
|
+
refresh_token: 'new-refresh-token',
|
|
91
|
+
user_id: 'user-123',
|
|
92
|
+
// expires_in is missing
|
|
93
|
+
}),
|
|
94
|
+
};
|
|
95
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse));
|
|
96
|
+
|
|
97
|
+
const result = await refreshFirebaseToken(mockStoredCredentials);
|
|
98
|
+
|
|
99
|
+
expect(result.firebaseTokenExpiry).toBe(now + 3600 * 1000);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('should default to 3600 when expires_in is non-numeric', async () => {
|
|
103
|
+
const now = Date.now();
|
|
104
|
+
vi.spyOn(Date, 'now').mockReturnValue(now);
|
|
105
|
+
|
|
106
|
+
const mockResponse = {
|
|
107
|
+
ok: true,
|
|
108
|
+
json: vi.fn().mockResolvedValue({
|
|
109
|
+
id_token: 'new-id-token',
|
|
110
|
+
refresh_token: 'new-refresh-token',
|
|
111
|
+
expires_in: 'invalid',
|
|
112
|
+
user_id: 'user-123',
|
|
113
|
+
}),
|
|
114
|
+
};
|
|
115
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse));
|
|
116
|
+
|
|
117
|
+
const result = await refreshFirebaseToken(mockStoredCredentials);
|
|
118
|
+
|
|
119
|
+
expect(result.firebaseTokenExpiry).toBe(now + 3600 * 1000);
|
|
120
|
+
});
|
|
121
|
+
});
|