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.
Files changed (155) hide show
  1. package/.oxlintrc.json +49 -0
  2. package/LICENSE +21 -0
  3. package/README.md +631 -0
  4. package/bin/cli.js +20 -0
  5. package/bin/validate-cwd.js +41 -0
  6. package/dist/config/__tests__/config.test.d.ts +2 -0
  7. package/dist/config/__tests__/config.test.js +262 -0
  8. package/dist/config/__tests__/credentials.test.d.ts +2 -0
  9. package/dist/config/__tests__/credentials.test.js +360 -0
  10. package/dist/config/config.d.ts +33 -0
  11. package/dist/config/config.js +185 -0
  12. package/dist/config/credentials.d.ts +75 -0
  13. package/dist/config/credentials.js +259 -0
  14. package/dist/config/server-selection.d.ts +40 -0
  15. package/dist/config/server-selection.js +130 -0
  16. package/dist/connection/__tests__/firebase-auth.test.d.ts +2 -0
  17. package/dist/connection/__tests__/firebase-auth.test.js +96 -0
  18. package/dist/connection/__tests__/hmac.test.d.ts +2 -0
  19. package/dist/connection/__tests__/hmac.test.js +372 -0
  20. package/dist/connection/auth.d.ts +13 -0
  21. package/dist/connection/auth.js +91 -0
  22. package/dist/connection/firebase-auth.d.ts +40 -0
  23. package/dist/connection/firebase-auth.js +429 -0
  24. package/dist/connection/hmac.d.ts +24 -0
  25. package/dist/connection/hmac.js +109 -0
  26. package/dist/i18n/index.d.ts +25 -0
  27. package/dist/i18n/index.js +101 -0
  28. package/dist/i18n/locales/en.json +313 -0
  29. package/dist/i18n/locales/es.json +302 -0
  30. package/dist/i18n/locales/fr.json +302 -0
  31. package/dist/i18n/locales/id.json +302 -0
  32. package/dist/i18n/locales/ja.json +302 -0
  33. package/dist/i18n/locales/ko.json +302 -0
  34. package/dist/i18n/locales/locales/en.json +309 -0
  35. package/dist/i18n/locales/locales/es.json +302 -0
  36. package/dist/i18n/locales/locales/fr.json +302 -0
  37. package/dist/i18n/locales/locales/id.json +302 -0
  38. package/dist/i18n/locales/locales/ja.json +302 -0
  39. package/dist/i18n/locales/locales/ko.json +302 -0
  40. package/dist/i18n/locales/locales/pt.json +302 -0
  41. package/dist/i18n/locales/locales/zh-Hans.json +302 -0
  42. package/dist/i18n/locales/pt.json +302 -0
  43. package/dist/i18n/locales/zh-Hans.json +302 -0
  44. package/dist/index.d.ts +25 -0
  45. package/dist/index.js +493 -0
  46. package/dist/proxy/ProxyClient.d.ts +125 -0
  47. package/dist/proxy/ProxyClient.js +781 -0
  48. package/dist/proxy/ProxySocketWrapper.d.ts +43 -0
  49. package/dist/proxy/ProxySocketWrapper.js +98 -0
  50. package/dist/proxy/__tests__/ProxyClient.test.d.ts +2 -0
  51. package/dist/proxy/__tests__/ProxyClient.test.js +445 -0
  52. package/dist/proxy/__tests__/ProxySocketWrapper.test.d.ts +2 -0
  53. package/dist/proxy/__tests__/ProxySocketWrapper.test.js +190 -0
  54. package/dist/proxy/__tests__/handshake-validation.test.d.ts +2 -0
  55. package/dist/proxy/__tests__/handshake-validation.test.js +282 -0
  56. package/dist/proxy/__tests__/token-refresh-race.test.d.ts +14 -0
  57. package/dist/proxy/__tests__/token-refresh-race.test.js +173 -0
  58. package/dist/proxy/chunking.d.ts +53 -0
  59. package/dist/proxy/chunking.js +127 -0
  60. package/dist/proxy/handshake-validation.d.ts +21 -0
  61. package/dist/proxy/handshake-validation.js +49 -0
  62. package/dist/rpc/__tests__/router.test.d.ts +2 -0
  63. package/dist/rpc/__tests__/router.test.js +262 -0
  64. package/dist/rpc/router.d.ts +37 -0
  65. package/dist/rpc/router.js +132 -0
  66. package/dist/services/BrowserProxyService.d.ts +13 -0
  67. package/dist/services/BrowserProxyService.js +139 -0
  68. package/dist/services/FilesystemService.d.ts +99 -0
  69. package/dist/services/FilesystemService.js +742 -0
  70. package/dist/services/GitService.d.ts +243 -0
  71. package/dist/services/GitService.js +1439 -0
  72. package/dist/services/SearchService.d.ts +93 -0
  73. package/dist/services/SearchService.js +670 -0
  74. package/dist/services/TerminalService.d.ts +62 -0
  75. package/dist/services/TerminalService.js +337 -0
  76. package/dist/services/__tests__/BrowserProxyService.test.d.ts +2 -0
  77. package/dist/services/__tests__/BrowserProxyService.test.js +145 -0
  78. package/dist/services/__tests__/FilesystemService.test.d.ts +2 -0
  79. package/dist/services/__tests__/FilesystemService.test.js +609 -0
  80. package/dist/services/__tests__/GitService.test.d.ts +2 -0
  81. package/dist/services/__tests__/GitService.test.js +953 -0
  82. package/dist/services/__tests__/SearchService.test.d.ts +2 -0
  83. package/dist/services/__tests__/SearchService.test.js +384 -0
  84. package/dist/services/__tests__/TerminalService.test.d.ts +2 -0
  85. package/dist/services/__tests__/TerminalService.test.js +513 -0
  86. package/dist/setup/wizard.d.ts +10 -0
  87. package/dist/setup/wizard.js +172 -0
  88. package/dist/types.d.ts +196 -0
  89. package/dist/types.js +44 -0
  90. package/dist/utils/__tests__/gitignore.test.d.ts +2 -0
  91. package/dist/utils/__tests__/gitignore.test.js +127 -0
  92. package/dist/utils/gitignore.d.ts +24 -0
  93. package/dist/utils/gitignore.js +77 -0
  94. package/dist/utils/logger.d.ts +96 -0
  95. package/dist/utils/logger.js +456 -0
  96. package/dist/utils/project-dir.d.ts +51 -0
  97. package/dist/utils/project-dir.js +191 -0
  98. package/dist/utils/ripgrep.d.ts +34 -0
  99. package/dist/utils/ripgrep.js +148 -0
  100. package/dist/utils/tool-detection.d.ts +17 -0
  101. package/dist/utils/tool-detection.js +126 -0
  102. package/dist/watcher/FileWatcher.d.ts +10 -0
  103. package/dist/watcher/FileWatcher.js +42 -0
  104. package/package.json +70 -0
  105. package/src/config/__tests__/config.test.ts +318 -0
  106. package/src/config/__tests__/credentials.test.ts +494 -0
  107. package/src/config/config.ts +206 -0
  108. package/src/config/credentials.ts +302 -0
  109. package/src/config/server-selection.ts +150 -0
  110. package/src/connection/__tests__/firebase-auth.test.ts +121 -0
  111. package/src/connection/__tests__/hmac.test.ts +509 -0
  112. package/src/connection/auth.ts +140 -0
  113. package/src/connection/firebase-auth.ts +504 -0
  114. package/src/connection/hmac.ts +139 -0
  115. package/src/i18n/index.ts +119 -0
  116. package/src/i18n/locales/en.json +313 -0
  117. package/src/i18n/locales/es.json +302 -0
  118. package/src/i18n/locales/fr.json +302 -0
  119. package/src/i18n/locales/id.json +302 -0
  120. package/src/i18n/locales/ja.json +302 -0
  121. package/src/i18n/locales/ko.json +302 -0
  122. package/src/i18n/locales/pt.json +302 -0
  123. package/src/i18n/locales/zh-Hans.json +302 -0
  124. package/src/index.ts +542 -0
  125. package/src/proxy/ProxyClient.ts +968 -0
  126. package/src/proxy/ProxySocketWrapper.ts +113 -0
  127. package/src/proxy/__tests__/ProxyClient.test.ts +575 -0
  128. package/src/proxy/__tests__/ProxySocketWrapper.test.ts +251 -0
  129. package/src/proxy/__tests__/handshake-validation.test.ts +367 -0
  130. package/src/proxy/chunking.ts +162 -0
  131. package/src/proxy/handshake-validation.ts +64 -0
  132. package/src/rpc/__tests__/router.test.ts +400 -0
  133. package/src/rpc/router.ts +183 -0
  134. package/src/services/BrowserProxyService.ts +179 -0
  135. package/src/services/FilesystemService.ts +841 -0
  136. package/src/services/GitService.ts +1639 -0
  137. package/src/services/SearchService.ts +809 -0
  138. package/src/services/TerminalService.ts +413 -0
  139. package/src/services/__tests__/BrowserProxyService.test.ts +155 -0
  140. package/src/services/__tests__/FilesystemService.test.ts +1002 -0
  141. package/src/services/__tests__/GitService.test.ts +1552 -0
  142. package/src/services/__tests__/SearchService.test.ts +484 -0
  143. package/src/services/__tests__/TerminalService.test.ts +702 -0
  144. package/src/setup/wizard.ts +242 -0
  145. package/src/types/fossil-delta.d.ts +4 -0
  146. package/src/types.ts +287 -0
  147. package/src/utils/__tests__/gitignore.test.ts +174 -0
  148. package/src/utils/gitignore.ts +91 -0
  149. package/src/utils/logger.ts +578 -0
  150. package/src/utils/project-dir.ts +218 -0
  151. package/src/utils/ripgrep.ts +180 -0
  152. package/src/utils/tool-detection.ts +141 -0
  153. package/src/watcher/FileWatcher.ts +53 -0
  154. package/tsconfig.json +24 -0
  155. package/vitest.config.ts +19 -0
@@ -0,0 +1,429 @@
1
+ /**
2
+ * Firebase authentication with local callback server
3
+ * Opens browser for OAuth flow, captures token via localhost POST
4
+ *
5
+ * Security features:
6
+ * - Token sent via POST body (not in URL) to prevent leaking via browser history/referrer
7
+ * - State parameter for CSRF protection
8
+ * - Localhost-only callback server
9
+ */
10
+ import * as http from 'http';
11
+ import * as crypto from 'crypto';
12
+ import jwt from 'jsonwebtoken';
13
+ import open from 'open';
14
+ import qrcode from 'qrcode-terminal';
15
+ import { saveCredentials } from '../config/credentials.js';
16
+ import { logAuth } from '../utils/logger.js';
17
+ import { t } from '../i18n/index.js';
18
+ import { fetchServerList, selectBestServer, isValidDomain } from '../config/server-selection.js';
19
+ const AUTH_TIMEOUT = 10 * 60 * 1000; // 10 minutes
20
+ const FIREBASE_AUTH_BASE_URL = 'https://spck.io/auth';
21
+ // Module-level references to allow aborting auth from outside (e.g., SIGINT handler)
22
+ let _authAbortController = null;
23
+ let _authCallbackServer = null;
24
+ /**
25
+ * Abort any in-progress authentication (e.g., on SIGINT).
26
+ * Cancels the pending fetch and closes the local callback server.
27
+ */
28
+ export function abortCurrentAuth() {
29
+ _authAbortController?.abort();
30
+ _authCallbackServer?.close();
31
+ _authAbortController = null;
32
+ _authCallbackServer = null;
33
+ }
34
+ /**
35
+ * Find an available port for the local callback server
36
+ */
37
+ async function getAvailablePort() {
38
+ return new Promise((resolve, reject) => {
39
+ const server = http.createServer();
40
+ server.listen(0, () => {
41
+ const address = server.address();
42
+ const port = typeof address === 'string' ? 0 : address?.port || 0;
43
+ server.close(() => {
44
+ resolve(port);
45
+ });
46
+ });
47
+ server.on('error', reject);
48
+ });
49
+ }
50
+ /**
51
+ * Long-poll proxy server for token (manual flow).
52
+ * The server holds the connection open until the browser posts the token (up to 30s),
53
+ * then returns 202 on timeout so we reconnect immediately — minimal round-trips.
54
+ */
55
+ async function pollServerForToken(serverUrl, code, signal) {
56
+ while (!signal.aborted) {
57
+ try {
58
+ const response = await fetch(`https://${serverUrl}/api/auth/token/poll`, {
59
+ method: 'POST',
60
+ headers: { 'Content-Type': 'application/json' },
61
+ body: JSON.stringify({ code }),
62
+ signal
63
+ });
64
+ if (response.status === 202) {
65
+ // Server hold timed out — reconnect immediately (no sleep needed)
66
+ continue;
67
+ }
68
+ if (!response.ok) {
69
+ // Brief pause on unexpected errors before retrying
70
+ await new Promise(resolve => setTimeout(resolve, 3000));
71
+ continue;
72
+ }
73
+ const result = await response.json();
74
+ if (result.idToken && result.refreshToken) {
75
+ return { token: result.idToken, refreshToken: result.refreshToken };
76
+ }
77
+ // Unexpected successful response without tokens — brief pause
78
+ await new Promise(resolve => setTimeout(resolve, 1000));
79
+ }
80
+ catch (error) {
81
+ if (error.name === 'AbortError')
82
+ return null;
83
+ // Network error — wait before retrying
84
+ await new Promise(resolve => setTimeout(resolve, 3000));
85
+ }
86
+ }
87
+ return null;
88
+ }
89
+ /**
90
+ * Parse POST body from request
91
+ */
92
+ function parsePostBody(req) {
93
+ return new Promise((resolve, reject) => {
94
+ let body = '';
95
+ req.on('data', chunk => { body += chunk.toString(); });
96
+ req.on('end', () => {
97
+ try {
98
+ // Try JSON first
99
+ if (req.headers['content-type']?.includes('application/json')) {
100
+ resolve(JSON.parse(body));
101
+ }
102
+ else {
103
+ // Parse URL-encoded form data
104
+ const params = new URLSearchParams(body);
105
+ const result = {};
106
+ params.forEach((value, key) => { result[key] = value; });
107
+ resolve(result);
108
+ }
109
+ }
110
+ catch (e) {
111
+ reject(e);
112
+ }
113
+ });
114
+ req.on('error', reject);
115
+ });
116
+ }
117
+ /**
118
+ * Authenticate with Firebase using secure local callback server
119
+ * Token is received via POST to prevent exposure in URLs
120
+ */
121
+ export async function authenticateWithFirebase() {
122
+ console.log('\n=== ' + t('auth.title') + ' ===\n');
123
+ // 1. Select best proxy server
124
+ const servers = await fetchServerList();
125
+ const { server: selectedServer } = await selectBestServer(servers);
126
+ const proxyServerUrl = selectedServer.url;
127
+ if (!isValidDomain(proxyServerUrl)) {
128
+ throw new Error(`Untrusted proxy server domain: ${proxyServerUrl}`);
129
+ }
130
+ // 2. Start local HTTP server
131
+ const port = await getAvailablePort();
132
+ console.log(t('auth.startingCallback', { port: String(port) }));
133
+ const callbackServer = http.createServer();
134
+ await new Promise(resolve => {
135
+ callbackServer.listen(port, () => resolve());
136
+ });
137
+ const redirectUrl = `http://localhost:${port}/callback`;
138
+ // 3. Generate code for both browser and manual flows
139
+ const code = crypto.randomBytes(32).toString('hex');
140
+ // 4. Build auth URLs
141
+ // Browser flow: localhost callback
142
+ const browserUrl = new URL(FIREBASE_AUTH_BASE_URL);
143
+ browserUrl.searchParams.set('redirect', redirectUrl);
144
+ browserUrl.searchParams.set('state', code);
145
+ // Manual flow: proxy server relay (server param is hostname only, https:// is assumed)
146
+ const manualUrl = new URL(FIREBASE_AUTH_BASE_URL);
147
+ manualUrl.searchParams.set('code', code);
148
+ manualUrl.searchParams.set('server', proxyServerUrl);
149
+ // 5. Open browser (primary method)
150
+ console.log(t('auth.openingBrowser') + '\n');
151
+ try {
152
+ await open(browserUrl.toString());
153
+ }
154
+ catch (error) {
155
+ console.warn('⚠️ ' + t('auth.couldNotOpenBrowser', { message: error.message }) + '\n');
156
+ }
157
+ // 6. Display fallback method
158
+ console.log(t('auth.manualAuthHint') + '\n');
159
+ // Display QR code
160
+ qrcode.generate(manualUrl.toString(), { small: true });
161
+ console.log('\n' + t('auth.visitUrl', { url: manualUrl.toString() }) + '\n');
162
+ console.log(t('auth.waiting') + '\n');
163
+ // 7. Wait for authentication from either source (browser or manual)
164
+ const abortController = new AbortController();
165
+ _authAbortController = abortController;
166
+ _authCallbackServer = callbackServer;
167
+ const result = await new Promise((resolve, reject) => {
168
+ const timeout = setTimeout(() => {
169
+ abortController.abort();
170
+ callbackServer.close();
171
+ reject(new Error('Authentication timeout after 10 minutes'));
172
+ }, AUTH_TIMEOUT);
173
+ // Start polling server API (manual flow)
174
+ const serverPolling = pollServerForToken(proxyServerUrl, code, abortController.signal).then(token => {
175
+ if (token) {
176
+ abortController.abort();
177
+ callbackServer.close();
178
+ return token;
179
+ }
180
+ return null;
181
+ });
182
+ // Handle localhost callback (browser flow)
183
+ callbackServer.on('request', async (req, res) => {
184
+ // Add CORS headers for POST requests from browser
185
+ res.setHeader('Access-Control-Allow-Origin', '*');
186
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
187
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
188
+ // Handle preflight
189
+ if (req.method === 'OPTIONS') {
190
+ res.writeHead(204);
191
+ res.end();
192
+ return;
193
+ }
194
+ // Only handle callback path
195
+ if (!req.url || !req.url.startsWith('/callback')) {
196
+ res.writeHead(404);
197
+ res.end('Not found');
198
+ return;
199
+ }
200
+ // Parse request parameters
201
+ let receivedState = null;
202
+ let token = null;
203
+ let refreshToken = null;
204
+ let error = null;
205
+ try {
206
+ if (req.method === 'POST') {
207
+ const body = await parsePostBody(req);
208
+ receivedState = body.state || null;
209
+ token = body.token || null;
210
+ refreshToken = body.refreshToken || null;
211
+ error = body.error || null;
212
+ }
213
+ else {
214
+ const url = new URL(req.url, `http://localhost:${port}`);
215
+ receivedState = url.searchParams.get('state');
216
+ token = url.searchParams.get('token');
217
+ refreshToken = url.searchParams.get('refreshToken');
218
+ error = url.searchParams.get('error');
219
+ }
220
+ }
221
+ catch (parseError) {
222
+ res.writeHead(400, { 'Content-Type': 'application/json' });
223
+ res.end(JSON.stringify({ success: false, error: 'Failed to parse request' }));
224
+ reject(new Error(`Failed to parse request: ${parseError.message}`));
225
+ return;
226
+ }
227
+ // Verify state/code (CSRF protection)
228
+ if (receivedState !== code) {
229
+ res.writeHead(400, { 'Content-Type': 'application/json' });
230
+ res.end(JSON.stringify({ success: false, error: 'Invalid state parameter' }));
231
+ reject(new Error('Invalid state parameter'));
232
+ return;
233
+ }
234
+ // Check for error
235
+ if (error) {
236
+ res.writeHead(400, { 'Content-Type': 'application/json' });
237
+ res.end(JSON.stringify({ success: false, error }));
238
+ reject(new Error(error));
239
+ return;
240
+ }
241
+ // Check for token
242
+ if (!token) {
243
+ res.writeHead(400, { 'Content-Type': 'application/json' });
244
+ res.end(JSON.stringify({ success: false, error: 'No token received' }));
245
+ reject(new Error('No token received'));
246
+ return;
247
+ }
248
+ // Check for refresh token BEFORE decoding
249
+ if (!refreshToken) {
250
+ res.writeHead(400, { 'Content-Type': 'application/json' });
251
+ res.end(JSON.stringify({ success: false, error: 'No refresh token received' }));
252
+ reject(new Error('No refresh token received'));
253
+ return;
254
+ }
255
+ // Decode and validate token
256
+ let decoded;
257
+ try {
258
+ decoded = jwt.decode(token);
259
+ if (!decoded || typeof decoded === 'string') {
260
+ throw new Error('Invalid token format');
261
+ }
262
+ if (!decoded.sub && !decoded.uid) {
263
+ throw new Error('Token missing user ID');
264
+ }
265
+ if (!decoded.exp) {
266
+ throw new Error('Token missing expiry');
267
+ }
268
+ }
269
+ catch (decodeError) {
270
+ res.writeHead(400, { 'Content-Type': 'application/json' });
271
+ res.end(JSON.stringify({ success: false, error: `Invalid token: ${decodeError.message}` }));
272
+ reject(new Error(`Invalid token: ${decodeError.message}`));
273
+ return;
274
+ }
275
+ // All validations passed - send success response
276
+ res.writeHead(200, { 'Content-Type': 'application/json' });
277
+ res.end(JSON.stringify({ success: true }));
278
+ // Clean up and resolve
279
+ clearTimeout(timeout);
280
+ abortController.abort(); // Stop server polling
281
+ const credentials = {
282
+ firebaseToken: token,
283
+ firebaseTokenExpiry: decoded.exp * 1000,
284
+ refreshToken,
285
+ userId: decoded.sub || decoded.uid
286
+ };
287
+ resolve(credentials);
288
+ });
289
+ callbackServer.on('error', (error) => {
290
+ clearTimeout(timeout);
291
+ abortController.abort();
292
+ reject(error);
293
+ });
294
+ // Handle server polling result (manual flow)
295
+ serverPolling.then(result => {
296
+ if (!result)
297
+ return; // Aborted or timed out
298
+ try {
299
+ const { token, refreshToken } = result;
300
+ // Decode token to get userId and expiry
301
+ const decoded = jwt.decode(token);
302
+ if (!decoded || typeof decoded === 'string') {
303
+ throw new Error('Invalid token format');
304
+ }
305
+ if (!decoded.sub && !decoded.uid) {
306
+ throw new Error('Token missing user ID');
307
+ }
308
+ if (!decoded.exp) {
309
+ throw new Error('Token missing expiry');
310
+ }
311
+ clearTimeout(timeout);
312
+ const credentials = {
313
+ firebaseToken: token,
314
+ firebaseTokenExpiry: decoded.exp * 1000,
315
+ refreshToken,
316
+ userId: decoded.sub || decoded.uid
317
+ };
318
+ resolve(credentials);
319
+ }
320
+ catch (error) {
321
+ reject(new Error(`Invalid token from server: ${error.message}`));
322
+ }
323
+ }).catch(error => {
324
+ clearTimeout(timeout);
325
+ abortController.abort();
326
+ callbackServer.close();
327
+ reject(error);
328
+ });
329
+ });
330
+ // 8. Close server and clear module-level references
331
+ callbackServer.close();
332
+ _authAbortController = null;
333
+ _authCallbackServer = null;
334
+ // 9. Save credentials
335
+ saveCredentials(result);
336
+ logAuth('firebase_auth_success', {
337
+ userId: result.userId,
338
+ method: 'firebase'
339
+ });
340
+ console.log('✅ ' + t('auth.success'));
341
+ console.log(' ' + t('auth.userId', { userId: result.userId }) + '\n');
342
+ return result;
343
+ }
344
+ // Firebase API key for token refresh
345
+ const FIREBASE_API_KEY = 'AIzaSyCFgtHhWiM-EdFBdiDw9ISHfcGOqbV3OCU';
346
+ /**
347
+ * Refresh Firebase ID token using refresh token
348
+ *
349
+ * Firebase Token Refresh Protocol:
350
+ * - Endpoint: https://securetoken.googleapis.com/v1/token?key={API_KEY}
351
+ * - Method: POST with application/x-www-form-urlencoded
352
+ * - Body: grant_type=refresh_token&refresh_token={REFRESH_TOKEN}
353
+ * - Response: { id_token, refresh_token, expires_in, token_type, user_id }
354
+ * - ID tokens expire after 1 hour (3600 seconds)
355
+ * - Refresh tokens are long-lived but can be revoked
356
+ *
357
+ * @see https://firebase.google.com/docs/reference/rest/auth#section-refresh-token
358
+ */
359
+ export async function refreshFirebaseToken(storedCredentials) {
360
+ if (!storedCredentials.refreshToken) {
361
+ console.log('⚠️ ' + t('auth.noRefreshToken'));
362
+ return authenticateWithFirebase();
363
+ }
364
+ try {
365
+ const response = await fetch(`https://securetoken.googleapis.com/v1/token?key=${FIREBASE_API_KEY}`, {
366
+ method: 'POST',
367
+ headers: {
368
+ 'Content-Type': 'application/x-www-form-urlencoded'
369
+ },
370
+ body: `grant_type=refresh_token&refresh_token=${encodeURIComponent(storedCredentials.refreshToken)}`
371
+ });
372
+ if (!response.ok) {
373
+ const errorData = await response.json().catch(() => ({}));
374
+ const errorMessage = errorData.error?.message || `HTTP ${response.status}`;
375
+ // Handle specific Firebase errors
376
+ if (errorMessage === 'TOKEN_EXPIRED' || errorMessage === 'INVALID_REFRESH_TOKEN') {
377
+ logAuth('token_refresh_failed', {
378
+ userId: storedCredentials.userId,
379
+ reason: errorMessage
380
+ }, 'warn');
381
+ console.log('⚠️ ' + t('auth.refreshTokenExpired'));
382
+ return authenticateWithFirebase();
383
+ }
384
+ throw new Error(`Token refresh failed: ${errorMessage}`);
385
+ }
386
+ const data = await response.json();
387
+ // Firebase returns: id_token, refresh_token, expires_in, token_type, user_id
388
+ const newToken = data.id_token;
389
+ const newRefreshToken = data.refresh_token;
390
+ const parsedExpiresIn = parseInt(data.expires_in, 10);
391
+ const expiresIn = isNaN(parsedExpiresIn) ? 3600 : parsedExpiresIn; // Default 1 hour
392
+ const userId = data.user_id;
393
+ if (!newToken) {
394
+ throw new Error('No ID token in refresh response');
395
+ }
396
+ // Build full credentials with fresh ID token
397
+ const fullCredentials = {
398
+ firebaseToken: newToken,
399
+ firebaseTokenExpiry: Date.now() + (expiresIn * 1000),
400
+ refreshToken: newRefreshToken || storedCredentials.refreshToken,
401
+ userId: userId || storedCredentials.userId
402
+ };
403
+ // Save only refreshToken + userId to disk (not the ephemeral ID token)
404
+ saveCredentials({
405
+ refreshToken: fullCredentials.refreshToken,
406
+ userId: fullCredentials.userId,
407
+ proxyServerUrl: storedCredentials.proxyServerUrl
408
+ });
409
+ return fullCredentials;
410
+ }
411
+ catch (error) {
412
+ logAuth('token_refresh_error', {
413
+ userId: storedCredentials.userId,
414
+ error: error.message
415
+ }, 'error');
416
+ console.error(t('auth.tokenRefreshError', { message: error.message }));
417
+ console.log('⚠️ ' + t('auth.tokenRefreshFailed'));
418
+ return authenticateWithFirebase();
419
+ }
420
+ }
421
+ /**
422
+ * Get a valid Firebase ID token by refreshing from stored credentials
423
+ * Always generates a fresh ID token using the refresh token
424
+ */
425
+ export async function getValidFirebaseToken(storedCredentials) {
426
+ console.log('🔄 ' + t('auth.generatingToken'));
427
+ return refreshFirebaseToken(storedCredentials);
428
+ }
429
+ //# sourceMappingURL=firebase-auth.js.map
@@ -0,0 +1,24 @@
1
+ /**
2
+ * HMAC message signing validation with replay attack prevention
3
+ */
4
+ import { JSONRPCRequest } from '../types.js';
5
+ /**
6
+ * Get statistics about nonce tracking (for testing/monitoring)
7
+ */
8
+ export declare function getNonceStats(): {
9
+ total: number;
10
+ active: number;
11
+ };
12
+ /**
13
+ * Clear all tracked nonces (for testing)
14
+ */
15
+ export declare function clearNonces(): void;
16
+ /**
17
+ * Validate HMAC signature on JSON-RPC request
18
+ */
19
+ export declare function validateHMAC(message: JSONRPCRequest, signingKey: string): boolean;
20
+ /**
21
+ * Validate HMAC or throw error
22
+ */
23
+ export declare function requireValidHMAC(message: JSONRPCRequest, signingKey: string): void;
24
+ //# sourceMappingURL=hmac.d.ts.map
@@ -0,0 +1,109 @@
1
+ /**
2
+ * HMAC message signing validation with replay attack prevention
3
+ */
4
+ import * as crypto from 'crypto';
5
+ import { ErrorCode, createRPCError } from '../types.js';
6
+ /**
7
+ * Nonce tracking to prevent replay attacks
8
+ * Stores nonces with their expiry timestamps
9
+ */
10
+ const seenNonces = new Map();
11
+ /**
12
+ * Clean up expired nonces from the tracking map
13
+ */
14
+ function cleanExpiredNonces() {
15
+ const now = Date.now();
16
+ for (const [nonce, expiry] of seenNonces.entries()) {
17
+ if (expiry < now) {
18
+ seenNonces.delete(nonce);
19
+ }
20
+ }
21
+ }
22
+ /**
23
+ * Get statistics about nonce tracking (for testing/monitoring)
24
+ */
25
+ export function getNonceStats() {
26
+ const now = Date.now();
27
+ let active = 0;
28
+ for (const expiry of seenNonces.values()) {
29
+ if (expiry >= now) {
30
+ active++;
31
+ }
32
+ }
33
+ return { total: seenNonces.size, active };
34
+ }
35
+ /**
36
+ * Clear all tracked nonces (for testing)
37
+ */
38
+ export function clearNonces() {
39
+ seenNonces.clear();
40
+ }
41
+ /**
42
+ * Validate HMAC signature on JSON-RPC request
43
+ */
44
+ export function validateHMAC(message, signingKey) {
45
+ if (!message.hmac || !message.timestamp) {
46
+ return false;
47
+ }
48
+ // Reconstruct the message that was signed (must match client's _computeHMAC)
49
+ // Client uses: const { timestamp, hmac, ...rest } = request
50
+ // So we need to include all fields except timestamp and hmac
51
+ const payload = {
52
+ jsonrpc: message.jsonrpc,
53
+ method: message.method,
54
+ params: message.params,
55
+ id: message.id,
56
+ nonce: message.nonce
57
+ };
58
+ // Include deviceId if present (client includes it)
59
+ if ('deviceId' in message) {
60
+ payload.deviceId = message.deviceId;
61
+ }
62
+ const messageToSign = message.timestamp + JSON.stringify(payload);
63
+ // Compute HMAC
64
+ const expectedHmac = crypto
65
+ .createHmac('sha256', signingKey)
66
+ .update(messageToSign)
67
+ .digest('hex');
68
+ // Check lengths match before constant-time comparison
69
+ if (message.hmac.length !== expectedHmac.length) {
70
+ return false;
71
+ }
72
+ // Compare with provided HMAC (constant-time comparison)
73
+ try {
74
+ return crypto.timingSafeEqual(Buffer.from(message.hmac), Buffer.from(expectedHmac));
75
+ }
76
+ catch {
77
+ return false;
78
+ }
79
+ }
80
+ /**
81
+ * Validate HMAC or throw error
82
+ */
83
+ export function requireValidHMAC(message, signingKey) {
84
+ if (!validateHMAC(message, signingKey)) {
85
+ throw createRPCError(ErrorCode.HMAC_VALIDATION_FAILED, 'HMAC validation failed - message signature invalid or missing');
86
+ }
87
+ // Check timestamp is recent (within 2 minutes)
88
+ if (message.timestamp) {
89
+ const now = Date.now();
90
+ const age = now - message.timestamp;
91
+ const maxAge = 2 * 60 * 1000; // 2 minutes (reduced from 5 to mitigate replay attacks)
92
+ if (age > maxAge || age < -60000) {
93
+ // Allow 1 minute clock skew
94
+ throw createRPCError(ErrorCode.HMAC_VALIDATION_FAILED, 'Message timestamp too old or invalid', { timestamp: message.timestamp, serverTime: now });
95
+ }
96
+ }
97
+ // Check nonce to prevent replay attacks
98
+ if (seenNonces.has(message.nonce)) {
99
+ throw createRPCError(ErrorCode.HMAC_VALIDATION_FAILED, 'Duplicate nonce detected - possible replay attack', { nonce: message.nonce });
100
+ }
101
+ // Store nonce with expiry (2 minutes from now)
102
+ const maxAge = 2 * 60 * 1000;
103
+ seenNonces.set(message.nonce, Date.now() + maxAge);
104
+ // Clean up expired nonces periodically
105
+ if (seenNonces.size > 1000) {
106
+ cleanExpiredNonces();
107
+ }
108
+ }
109
+ //# sourceMappingURL=hmac.js.map
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Lightweight i18n system for the CLI
3
+ * No external dependencies — uses JSON locale files with dot-path keys and {{variable}} interpolation
4
+ */
5
+ /**
6
+ * Detect locale using os-locale (reads LC_ALL, LANG, LANGUAGE, and OS-specific APIs)
7
+ */
8
+ export declare function detectLocale(): string;
9
+ /**
10
+ * Set locale explicitly (e.g. from --locale CLI flag)
11
+ */
12
+ export declare function setLocale(locale: string): void;
13
+ /**
14
+ * Get the current locale
15
+ */
16
+ export declare function getLocale(): string;
17
+ /**
18
+ * Translate a key with optional parameter interpolation
19
+ *
20
+ * @param key - Dot-path key (e.g. 'tools.gitDetected')
21
+ * @param params - Optional parameters for {{variable}} interpolation
22
+ * @returns Translated string, falling back to English, then the key itself
23
+ */
24
+ export declare function t(key: string, params?: Record<string, string | number>): string;
25
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Lightweight i18n system for the CLI
3
+ * No external dependencies — uses JSON locale files with dot-path keys and {{variable}} interpolation
4
+ */
5
+ import { readFileSync } from 'fs';
6
+ import { dirname, join } from 'path';
7
+ import { fileURLToPath } from 'url';
8
+ import osLocale from 'os-locale';
9
+ // Support both CJS (ts-jest) and ESM (runtime) contexts
10
+ // eslint-disable-next-line no-eval
11
+ const __dirname = dirname(fileURLToPath(import.meta.url));
12
+ const localesDir = join(__dirname, 'locales');
13
+ function loadLocale(file) {
14
+ try {
15
+ return JSON.parse(readFileSync(join(localesDir, file), 'utf-8'));
16
+ }
17
+ catch {
18
+ return {};
19
+ }
20
+ }
21
+ const en = loadLocale('en.json');
22
+ const locales = {
23
+ en,
24
+ es: loadLocale('es.json'),
25
+ fr: loadLocale('fr.json'),
26
+ id: loadLocale('id.json'),
27
+ ja: loadLocale('ja.json'),
28
+ ko: loadLocale('ko.json'),
29
+ pt: loadLocale('pt.json'),
30
+ 'zh-Hans': loadLocale('zh-Hans.json'),
31
+ };
32
+ const SUPPORTED_LOCALES = Object.keys(locales);
33
+ let currentLocale = 'en';
34
+ /**
35
+ * Map system locale string (e.g. "ja_JP.UTF-8") to a supported locale code
36
+ */
37
+ function mapLocale(raw) {
38
+ // Normalize: lowercase, replace - with _
39
+ const normalized = raw.toLowerCase().replace(/-/g, '_');
40
+ // zh_cn / zh_sg → zh-Hans
41
+ if (normalized.startsWith('zh_cn') || normalized.startsWith('zh_sg') || normalized === 'zh_hans' || normalized.startsWith('zh_hans')) {
42
+ return 'zh-Hans';
43
+ }
44
+ // Extract language code (before _ or .)
45
+ const lang = normalized.split(/[_.]/)[0];
46
+ if (SUPPORTED_LOCALES.includes(lang)) {
47
+ return lang;
48
+ }
49
+ return 'en';
50
+ }
51
+ /**
52
+ * Detect locale using os-locale (reads LC_ALL, LANG, LANGUAGE, and OS-specific APIs)
53
+ */
54
+ export function detectLocale() {
55
+ const raw = osLocale();
56
+ currentLocale = mapLocale(raw);
57
+ return currentLocale;
58
+ }
59
+ /**
60
+ * Set locale explicitly (e.g. from --locale CLI flag)
61
+ */
62
+ export function setLocale(locale) {
63
+ currentLocale = mapLocale(locale);
64
+ }
65
+ /**
66
+ * Get the current locale
67
+ */
68
+ export function getLocale() {
69
+ return currentLocale;
70
+ }
71
+ /**
72
+ * Resolve a dot-path key from a translations object
73
+ * e.g. resolve('tools.gitDetected', translations) → translations.tools.gitDetected
74
+ */
75
+ function resolve(key, obj) {
76
+ const parts = key.split('.');
77
+ let current = obj;
78
+ for (const part of parts) {
79
+ if (current == null || typeof current !== 'object')
80
+ return undefined;
81
+ current = current[part];
82
+ }
83
+ return typeof current === 'string' ? current : undefined;
84
+ }
85
+ /**
86
+ * Translate a key with optional parameter interpolation
87
+ *
88
+ * @param key - Dot-path key (e.g. 'tools.gitDetected')
89
+ * @param params - Optional parameters for {{variable}} interpolation
90
+ * @returns Translated string, falling back to English, then the key itself
91
+ */
92
+ export function t(key, params) {
93
+ let text = resolve(key, locales[currentLocale]) ?? resolve(key, en) ?? key;
94
+ if (params) {
95
+ for (const [name, value] of Object.entries(params)) {
96
+ text = text.replace(new RegExp(`\\{\\{${name}\\}\\}`, 'g'), String(value));
97
+ }
98
+ }
99
+ return text;
100
+ }
101
+ //# sourceMappingURL=index.js.map