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,781 @@
1
+ /**
2
+ * Proxy Client - Connects CLI to proxy server
3
+ * Handles authentication, message relay, and handshake protocol
4
+ */
5
+ import { io } from 'socket.io-client';
6
+ import * as crypto from 'crypto';
7
+ import qrcode from 'qrcode-terminal';
8
+ import { verifyFirebaseToken } from '../connection/auth.js';
9
+ import { refreshFirebaseToken } from '../connection/firebase-auth.js';
10
+ import { loadCredentials, loadGlobalConfig, saveGlobalConfig } from '../config/credentials.js';
11
+ import { ProxySocketWrapper } from './ProxySocketWrapper.js';
12
+ import { ErrorCode, createRPCError, } from '../types.js';
13
+ import { saveConnectionSettings, } from '../config/credentials.js';
14
+ import { t } from '../i18n/index.js';
15
+ import { logAuth, logConnection } from '../utils/logger.js';
16
+ import { RPCRouter } from '../rpc/router.js';
17
+ import { validateHandshakeTimestamp } from './handshake-validation.js';
18
+ import { requireValidHMAC } from '../connection/hmac.js';
19
+ import { needsChunking, chunkMessage } from './chunking.js';
20
+ function formatTimeUntilReset(resetTime) {
21
+ const now = Date.now();
22
+ const target = resetTime ?? Date.UTC(new Date().getUTCFullYear(), new Date().getUTCMonth(), new Date().getUTCDate() + 1);
23
+ const msLeft = Math.max(0, target - now);
24
+ const h = Math.floor(msLeft / 3600000);
25
+ const m = Math.floor((msLeft % 3600000) / 60000);
26
+ if (h > 0)
27
+ return `${h}h ${m}m`;
28
+ return `${m}m`;
29
+ }
30
+ const KILL_TIMEOUT = 5000; // 5 seconds
31
+ const SECRET_LENGTH = 33;
32
+ export class ProxyClient {
33
+ constructor(options) {
34
+ this.options = options;
35
+ this.socket = null;
36
+ this.connectionSettings = null;
37
+ this.activeConnections = new Map();
38
+ this.knownDeviceIds = new Set(loadGlobalConfig().knownDeviceIds); // Track known device IDs
39
+ this.tokenRefreshAttempted = false;
40
+ this.config = options.config;
41
+ this.tools = options.tools;
42
+ this.firebaseToken = options.firebaseToken;
43
+ this.userId = options.userId;
44
+ }
45
+ /**
46
+ * Connect to proxy server
47
+ */
48
+ async connect() {
49
+ const { existingConnectionSettings } = this.options;
50
+ const relayServer = this.options.proxyServerUrl;
51
+ console.log(`\n=== ${t('connection.title')} ===\n`);
52
+ console.log(` ${t('connection.relayServer', { server: relayServer })}\n`);
53
+ // Determine if we're renewing an existing connection
54
+ const existingToken = existingConnectionSettings?.serverToken;
55
+ // Create Socket.IO client - connect to /listen namespace
56
+ // Note: /listen is a Socket.IO namespace, not an HTTP path
57
+ const namespaceUrl = `wss://${relayServer}/listen`;
58
+ this.socket = io(namespaceUrl, {
59
+ transports: ['websocket'],
60
+ auth: {
61
+ firebaseToken: this.firebaseToken,
62
+ serverToken: existingToken,
63
+ },
64
+ reconnection: true,
65
+ reconnectionAttempts: 10,
66
+ reconnectionDelay: 1000,
67
+ reconnectionDelayMax: 10000,
68
+ timeout: 25000,
69
+ });
70
+ // Setup event handlers
71
+ this.setupEventHandlers();
72
+ // Wait for authentication
73
+ await this.waitForAuthentication();
74
+ }
75
+ /**
76
+ * Refresh firebase token and reconnect
77
+ */
78
+ async refreshTokenAndReconnect() {
79
+ try {
80
+ logAuth('token_refresh_start', { userId: this.userId });
81
+ // Load stored credentials to get refresh token
82
+ const storedCredentials = loadCredentials();
83
+ if (!storedCredentials) {
84
+ throw new Error('No stored credentials available for token refresh');
85
+ }
86
+ // Refresh the Firebase token
87
+ const newCredentials = await refreshFirebaseToken(storedCredentials);
88
+ // Update internal state
89
+ this.firebaseToken = newCredentials.firebaseToken;
90
+ this.userId = newCredentials.userId;
91
+ logAuth('token_refresh_success', { userId: this.userId });
92
+ // Disconnect current socket
93
+ if (this.socket) {
94
+ this.socket.removeAllListeners();
95
+ this.socket.disconnect();
96
+ this.socket = null;
97
+ }
98
+ // Preserve connection settings for secret reuse, clear active connections
99
+ this.activeConnections.clear();
100
+ // Reconnect with new token
101
+ logAuth('reconnect_with_new_token', { userId: this.userId });
102
+ await this.connect();
103
+ }
104
+ catch (error) {
105
+ throw new Error(`Failed to refresh token: ${error.message}`);
106
+ }
107
+ }
108
+ /**
109
+ * Setup all event handlers
110
+ */
111
+ setupEventHandlers() {
112
+ if (!this.socket)
113
+ return;
114
+ // Authentication successful
115
+ this.socket.on('authenticated', this.handleAuthenticated.bind(this));
116
+ // Client events
117
+ this.socket.on('client_connecting', this.handleClientConnecting.bind(this));
118
+ this.socket.on('multiple_connection_attempt', this.handleMultipleConnection.bind(this));
119
+ this.socket.on('client_message', this.handleClientMessage.bind(this));
120
+ this.socket.on('client_disconnected', this.handleClientDisconnected.bind(this));
121
+ // Error handling (async wrapper for handleError)
122
+ this.socket.on('error', (error) => {
123
+ this.handleError(error).catch((err) => {
124
+ console.error(`\n❌ ${t('connection.unhandledError')}`, err.message);
125
+ process.exit(1);
126
+ });
127
+ });
128
+ // Connection state
129
+ this.socket.on('disconnect', this.handleDisconnect.bind(this));
130
+ this.socket.on('reconnect_attempt', this.handleReconnectAttempt.bind(this));
131
+ this.socket.on('reconnect', this.handleReconnect.bind(this));
132
+ this.socket.on('reconnect_failed', this.handleReconnectFailed.bind(this));
133
+ }
134
+ /**
135
+ * Wait for authentication to complete
136
+ */
137
+ waitForAuthentication() {
138
+ return new Promise((resolve, reject) => {
139
+ if (!this.socket) {
140
+ reject(new Error('Socket not initialized'));
141
+ return;
142
+ }
143
+ const timeout = setTimeout(() => {
144
+ reject(new Error('Authentication timeout'));
145
+ }, 30000); // 30 second timeout
146
+ this.socket.once('authenticated', () => {
147
+ clearTimeout(timeout);
148
+ resolve();
149
+ });
150
+ this.socket.once('error', (error) => {
151
+ clearTimeout(timeout);
152
+ const enhancedError = new Error(`${t('connection.connectError', { message: error.message || error.toString() })}\n` +
153
+ ` ${t('connection.connectErrorNamespace')}\n` +
154
+ ` ${t('connection.connectErrorType', { type: error.type || 'unknown' })}`);
155
+ reject(enhancedError);
156
+ });
157
+ this.socket.once('connect_error', (error) => {
158
+ clearTimeout(timeout);
159
+ const currentProxyUrl = `wss://${this.options.proxyServerUrl}`;
160
+ const enhancedError = new Error(`${t('connection.connectFailed')}\n` +
161
+ ` ${t('connection.connectFailedUrl', { url: currentProxyUrl })}\n` +
162
+ ` ${t('connection.connectFailedError', { message: error.message || error.toString() })}\n` +
163
+ ` \n` +
164
+ ` ${t('connection.connectFailedCauses')}\n` +
165
+ ` ${t('connection.connectFailedCause1')}\n` +
166
+ ` ${t('connection.connectFailedCause2')}`);
167
+ reject(enhancedError);
168
+ });
169
+ });
170
+ }
171
+ /**
172
+ * Handle authenticated event from proxy
173
+ */
174
+ handleAuthenticated(data) {
175
+ logAuth('proxy_authenticated', { userId: data.userId, clientId: data.clientId });
176
+ // Track if this is a new CLI session (vs automatic reconnection in same session)
177
+ const isNewSession = this.connectionSettings === null;
178
+ // Reuse existing secret if clientId matches, otherwise generate new one
179
+ // Check current connectionSettings first (for reconnections), then initial options
180
+ const existingSettings = this.connectionSettings || this.options.existingConnectionSettings;
181
+ let secret;
182
+ if (existingSettings && existingSettings.clientId === data.clientId) {
183
+ // Same clientId - reuse the secret
184
+ secret = existingSettings.secret;
185
+ console.log(` ${t('connection.reusingSecret')}`);
186
+ }
187
+ else {
188
+ // New clientId or no existing settings - generate new secret
189
+ secret = crypto.randomBytes(SECRET_LENGTH).toString('base64url');
190
+ console.log(` ${t('connection.generatedSecret')}`);
191
+ }
192
+ // Save connection settings
193
+ this.connectionSettings = {
194
+ serverToken: data.token,
195
+ serverTokenExpiry: data.expiresAt,
196
+ clientId: data.clientId,
197
+ secret,
198
+ userId: data.userId,
199
+ connectedAt: Date.now(),
200
+ };
201
+ saveConnectionSettings(this.connectionSettings);
202
+ // Show free tier notice on new sessions
203
+ if (isNewSession && data.freeTierInfo) {
204
+ const { dailyLimitSeconds, usedSeconds } = data.freeTierInfo;
205
+ const limitMinutes = Math.floor(dailyLimitSeconds / 60);
206
+ const usedMinutes = Math.floor(usedSeconds / 60);
207
+ const remainingMinutes = Math.max(0, limitMinutes - usedMinutes);
208
+ console.log(`\nℹ️ ${t('connection.freeTierNotice', { used: usedMinutes, limit: limitMinutes, remaining: remainingMinutes })}`);
209
+ console.log(` ${t('connection.freeTierUpgrade')}\n`);
210
+ }
211
+ // Display QR code on new CLI session or if clientId changed (not on automatic reconnections)
212
+ if (isNewSession || this.connectionSettings.clientId !== existingSettings?.clientId) {
213
+ this.displayQRCode();
214
+ }
215
+ }
216
+ /**
217
+ * Display QR code for client connection
218
+ */
219
+ displayQRCode() {
220
+ if (!this.connectionSettings)
221
+ return;
222
+ const { clientId, secret } = this.connectionSettings;
223
+ // Build connection URL with relay server and optional server name
224
+ const relayServer = this.options.proxyServerUrl;
225
+ let url = `spck://connect?clientId=${clientId}&secret=${secret}&rs=${encodeURIComponent(relayServer)}`;
226
+ if (this.config.name) {
227
+ url += `&name=${encodeURIComponent(this.config.name)}`;
228
+ }
229
+ console.log('\n' + '='.repeat(60));
230
+ console.log(t('connection.scanQR'));
231
+ console.log('='.repeat(60) + '\n');
232
+ // Generate ASCII QR code
233
+ qrcode.generate(url, { small: true });
234
+ console.log('\n' + '-'.repeat(60));
235
+ console.log(t('connection.clientId', { id: clientId }));
236
+ console.log(t('connection.secret', { secret }));
237
+ if (this.config.name) {
238
+ console.log(t('connection.name', { name: this.config.name }));
239
+ }
240
+ console.log(t('connection.relayServerLabel', { server: relayServer }));
241
+ console.log('-'.repeat(60));
242
+ console.log(`\n${t('connection.relayServerMismatch')}`);
243
+ console.log(`${t('connection.relayServerMismatchHint', { server: relayServer })}\n`);
244
+ }
245
+ /**
246
+ * Handle client connecting event
247
+ */
248
+ handleClientConnecting(data) {
249
+ // Connection not yet authenticated, so we don't have deviceId yet
250
+ logConnection('connecting', undefined, { connectionId: data.connectionId });
251
+ }
252
+ /**
253
+ * Handle multiple connection attempt
254
+ */
255
+ handleMultipleConnection(data) {
256
+ logAuth('multiple_connection_attempt', {
257
+ existingConnections: data.existingConnections.length,
258
+ newConnectionId: data.newConnectionId,
259
+ userId: this.userId
260
+ }, 'warn');
261
+ console.warn('\n' + '⚠'.repeat(30));
262
+ console.warn(`⚠️ ${t('multipleConnection.detected')}`);
263
+ console.warn('⚠'.repeat(30));
264
+ console.warn(`\n${t('multipleConnection.existingCount', { count: data.existingConnections.length })}`);
265
+ console.warn(t('multipleConnection.newConnectionId', { id: data.newConnectionId }));
266
+ console.warn(`\n${t('multipleConnection.rejectedHint')}`);
267
+ console.warn(`${t('multipleConnection.restartHint')}\n`);
268
+ console.warn(`⚠️ ${t('multipleConnection.compromiseWarning')}`);
269
+ console.warn(` ${t('multipleConnection.compromiseHint')}\n`);
270
+ }
271
+ /**
272
+ * Handle client message (includes handshake and RPC)
273
+ */
274
+ async handleClientMessage(msg) {
275
+ const { connectionId, data } = msg;
276
+ try {
277
+ // Handle handshake protocol messages
278
+ if (data.type) {
279
+ await this.handleHandshakeMessage(connectionId, data);
280
+ return;
281
+ }
282
+ // Handle RPC messages (after handshake complete)
283
+ if (data.jsonrpc === '2.0') {
284
+ await this.handleRPCMessage(connectionId, data);
285
+ return;
286
+ }
287
+ const connection = this.activeConnections.get(connectionId);
288
+ const displayId = connection?.deviceId ?? '';
289
+ console.warn(`${t('connection.unknownMessageType', { deviceId: displayId })}:`, data);
290
+ }
291
+ catch (error) {
292
+ const connection = this.activeConnections.get(connectionId);
293
+ const displayId = connection?.deviceId ?? '';
294
+ console.error(`${t('connection.errorHandlingMessage', { deviceId: displayId })}:`, error.message);
295
+ // Send error response if it's an RPC message
296
+ if (data.id) {
297
+ this.sendToClient(connectionId, 'rpc', {
298
+ jsonrpc: '2.0',
299
+ error: createRPCError(ErrorCode.INTERNAL_ERROR, error.message),
300
+ id: data.id,
301
+ });
302
+ }
303
+ }
304
+ }
305
+ /**
306
+ * Handle handshake protocol messages
307
+ */
308
+ async handleHandshakeMessage(connectionId, data) {
309
+ switch (data.type) {
310
+ case 'auth':
311
+ await this.handleClientAuth(connectionId, data);
312
+ break;
313
+ case 'user_verification':
314
+ await this.handleUserVerification(connectionId, data.firebaseToken);
315
+ break;
316
+ case 'protocol_selected':
317
+ await this.handleProtocolSelection(connectionId, data.version);
318
+ break;
319
+ default:
320
+ console.warn(t('connection.unknownHandshakeType', { type: data.type }));
321
+ }
322
+ }
323
+ /**
324
+ * Handle client HMAC authentication
325
+ */
326
+ async handleClientAuth(connectionId, authMessage) {
327
+ try {
328
+ if (!this.connectionSettings) {
329
+ throw new Error('No connection settings available');
330
+ }
331
+ const { clientId, timestamp, nonce, hmac, deviceId } = authMessage;
332
+ // Verify deviceId is provided (required)
333
+ if (!deviceId) {
334
+ throw new Error('Device ID is required for authentication');
335
+ }
336
+ // Verify clientId matches
337
+ if (clientId !== this.connectionSettings.clientId) {
338
+ throw new Error('Client ID mismatch');
339
+ }
340
+ // Verify timestamp - replay attack prevention (1 minute tolerance)
341
+ const timestampValidation = validateHandshakeTimestamp(timestamp, {
342
+ maxAge: 60 * 1000, // 1 minute
343
+ clockSkewTolerance: 60 * 1000, // 1 minute
344
+ });
345
+ if (!timestampValidation.valid) {
346
+ throw new Error(timestampValidation.error);
347
+ }
348
+ // Verify HMAC signature (always includes deviceId)
349
+ const messageToVerify = { type: 'auth', clientId, timestamp, nonce, deviceId };
350
+ const expectedHmac = this.computeHMAC(messageToVerify, this.connectionSettings.secret);
351
+ if (hmac !== expectedHmac) {
352
+ throw new Error('Invalid HMAC signature');
353
+ }
354
+ // Check if this is a new device
355
+ const isNewDevice = !this.knownDeviceIds.has(deviceId);
356
+ if (isNewDevice) {
357
+ logAuth('new_device_connecting', {
358
+ deviceId,
359
+ userId: this.connectionSettings.userId,
360
+ firstConnection: true
361
+ }, 'warn');
362
+ console.log(`\n🆕 ${t('connection.newDevice', { deviceId })}`);
363
+ console.log(` ${t('connection.newDeviceWarning')}`);
364
+ console.log(` ${t('connection.newDeviceCompromised')}\n`);
365
+ this.knownDeviceIds.add(deviceId);
366
+ saveGlobalConfig({ knownDeviceIds: Array.from(this.knownDeviceIds) });
367
+ }
368
+ logConnection('authenticated', deviceId, {
369
+ connectionId,
370
+ userId: this.connectionSettings.userId
371
+ });
372
+ // Create socket wrapper for this connection
373
+ const socketWrapper = new ProxySocketWrapper(connectionId, this.connectionSettings.userId, this.sendToClient.bind(this), deviceId);
374
+ // Store connection as authenticated
375
+ const userVerificationRequired = this.config.security.userAuthenticationEnabled;
376
+ this.activeConnections.set(connectionId, {
377
+ authenticated: true,
378
+ userVerified: false,
379
+ userVerificationRequired,
380
+ connectedAt: Date.now(),
381
+ socketWrapper,
382
+ deviceId,
383
+ });
384
+ // Send success response
385
+ this.sendToClient(connectionId, 'handshake', {
386
+ type: 'auth_result',
387
+ success: true,
388
+ });
389
+ // Check if user authentication is required
390
+ if (userVerificationRequired) {
391
+ console.log(` ${t('connection.userVerifying')}`);
392
+ this.sendToClient(connectionId, 'handshake', {
393
+ type: 'request_user_verification',
394
+ message: 'Please provide Firebase authentication',
395
+ });
396
+ }
397
+ else {
398
+ // Skip to protocol negotiation
399
+ this.sendProtocolInfo(connectionId);
400
+ }
401
+ }
402
+ catch (error) {
403
+ const { deviceId } = authMessage;
404
+ logConnection('auth_failed', deviceId, {
405
+ connectionId,
406
+ error: error.message,
407
+ userId: this.connectionSettings?.userId
408
+ });
409
+ this.sendToClient(connectionId, 'handshake', {
410
+ type: 'auth_result',
411
+ success: false,
412
+ error: error.message,
413
+ });
414
+ }
415
+ }
416
+ /**
417
+ * Handle user verification (required when userAuthenticationEnabled)
418
+ */
419
+ async handleUserVerification(connectionId, firebaseToken) {
420
+ const connection = this.activeConnections.get(connectionId);
421
+ if (!connection || !connection.authenticated) {
422
+ const displayId = connection?.deviceId || connectionId;
423
+ console.warn(t('connection.rejectingUnauthenticated', { event: 'user_verification', deviceId: displayId }));
424
+ return;
425
+ }
426
+ const displayId = connection.deviceId || connectionId;
427
+ try {
428
+ // Verify Firebase token
429
+ // If userAuthenticationEnabled, restrict to the userId from connection settings
430
+ const allowedUids = this.config.security.userAuthenticationEnabled && this.connectionSettings?.userId
431
+ ? [this.connectionSettings.userId]
432
+ : [];
433
+ const payload = await verifyFirebaseToken(firebaseToken, 'spck-editor', allowedUids);
434
+ // If we get here, token is valid and UID matches (if userAuthenticationEnabled)
435
+ console.log(`✅ ${t('connection.userVerified', { deviceId: displayId, userId: payload.sub ?? '' })}`);
436
+ connection.userVerified = true;
437
+ // Continue to protocol negotiation
438
+ this.sendProtocolInfo(connectionId);
439
+ }
440
+ catch (error) {
441
+ console.error(`❌ ${t('connection.userVerifyFailed', { deviceId: displayId, message: error.message })}`);
442
+ // When user verification is required, reject on failure
443
+ if (connection.userVerificationRequired) {
444
+ this.sendToClient(connectionId, 'handshake', {
445
+ type: 'user_verification_result',
446
+ success: false,
447
+ error: error.message,
448
+ });
449
+ return;
450
+ }
451
+ console.log(` ${t('connection.userVerifyOptional')}`);
452
+ // Continue to protocol negotiation
453
+ this.sendProtocolInfo(connectionId);
454
+ }
455
+ }
456
+ /**
457
+ * Send protocol information to client
458
+ */
459
+ sendProtocolInfo(connectionId) {
460
+ const features = {
461
+ terminal: this.config.terminal.enabled,
462
+ git: this.tools.git,
463
+ fastSearch: this.tools.ripgrep,
464
+ browserProxy: this.config.browserProxy?.enabled ?? true,
465
+ };
466
+ this.sendToClient(connectionId, 'handshake', {
467
+ type: 'protocol_info',
468
+ minVersion: 1,
469
+ maxVersion: 1,
470
+ features,
471
+ });
472
+ }
473
+ /**
474
+ * Handle protocol version selection
475
+ */
476
+ async handleProtocolSelection(connectionId, version) {
477
+ // Security check: Verify connection is properly authenticated before completing handshake
478
+ const connection = this.activeConnections.get(connectionId);
479
+ const displayId = connection?.deviceId ?? '';
480
+ if (!connection || !connection.authenticated) {
481
+ console.warn(t('connection.rejectingUnauthenticated', { event: 'protocol_selected', deviceId: displayId }));
482
+ this.sendToClient(connectionId, 'handshake', {
483
+ type: 'error',
484
+ code: 'not_authenticated',
485
+ message: 'Authentication required before protocol selection',
486
+ });
487
+ return;
488
+ }
489
+ // Security check: If user verification is required, ensure it was completed
490
+ if (connection.userVerificationRequired && !connection.userVerified) {
491
+ console.warn(t('connection.rejectingUserVerification', { deviceId: displayId }));
492
+ this.sendToClient(connectionId, 'handshake', {
493
+ type: 'error',
494
+ code: 'user_verification_required',
495
+ message: 'User verification required before protocol selection',
496
+ });
497
+ return;
498
+ }
499
+ if (version !== 1) {
500
+ console.error(`❌ ${t('connection.protocolUnsupported', { version, deviceId: displayId })}`);
501
+ return;
502
+ }
503
+ console.log(`✅ ${t('connection.protocolNegotiated', { version, deviceId: displayId })}`);
504
+ // Mark connection as fully established
505
+ connection.handshakeComplete = true;
506
+ // Send connection established message
507
+ this.sendToClient(connectionId, 'handshake', {
508
+ type: 'connected',
509
+ message: 'Connection established',
510
+ });
511
+ // Notify proxy that handshake is complete
512
+ if (this.socket) {
513
+ this.socket.emit('handshake_complete', { connectionId });
514
+ }
515
+ logConnection('ready', connection.deviceId, {
516
+ connectionId,
517
+ protocolVersion: version
518
+ });
519
+ }
520
+ /**
521
+ * Handle RPC message from client
522
+ */
523
+ async handleRPCMessage(connectionId, message) {
524
+ // Check if connection is authenticated and handshake complete
525
+ const connection = this.activeConnections.get(connectionId);
526
+ const displayId = connection?.deviceId ?? '';
527
+ if (!connection || !connection.handshakeComplete) {
528
+ console.warn(t('connection.rejectingUnauthenticated', { event: 'RPC', deviceId: displayId }));
529
+ return;
530
+ }
531
+ // Distinguish between RPC request (has method) and RPC response (has result/error)
532
+ const isRequest = 'method' in message;
533
+ const isResponse = 'result' in message || 'error' in message;
534
+ if (isResponse) {
535
+ // This is a response from the client (e.g., auth response)
536
+ // Trigger 'rpc' event listeners on the socket wrapper
537
+ if (connection.socketWrapper) {
538
+ connection.socketWrapper.triggerEvent('rpc', message);
539
+ }
540
+ return;
541
+ }
542
+ if (!isRequest) {
543
+ console.warn(`${t('connection.invalidRpcMessage', { deviceId: displayId })}:`, message);
544
+ return;
545
+ }
546
+ // Handle RPC request
547
+ try {
548
+ // Verify HMAC signature - REQUIRED for all RPC requests
549
+ if (!this.connectionSettings?.secret) {
550
+ throw createRPCError(ErrorCode.INTERNAL_ERROR, 'Server configuration error: signing key not available');
551
+ }
552
+ requireValidHMAC(message, this.connectionSettings.secret);
553
+ // Route to appropriate service with socket wrapper
554
+ const result = await RPCRouter.route(message, connection.socketWrapper);
555
+ // Send response
556
+ const response = {
557
+ jsonrpc: '2.0',
558
+ result,
559
+ id: message.id || null,
560
+ };
561
+ this.sendToClient(connectionId, 'rpc', response);
562
+ }
563
+ catch (error) {
564
+ // Send error response
565
+ const response = {
566
+ jsonrpc: '2.0',
567
+ error: error.code && error.message ? error : createRPCError(ErrorCode.INTERNAL_ERROR, error.message || 'Internal error'),
568
+ id: message.id || null,
569
+ };
570
+ this.sendToClient(connectionId, 'rpc', response);
571
+ }
572
+ }
573
+ /**
574
+ * Send message to client via proxy
575
+ * Automatically chunks large payloads (>800kB)
576
+ */
577
+ sendToClient(connectionId, event, data) {
578
+ if (!this.socket)
579
+ return;
580
+ // Check if message needs chunking
581
+ if (needsChunking(data)) {
582
+ // Chunk the message
583
+ const chunks = chunkMessage(event, data);
584
+ const connection = this.activeConnections.get(connectionId);
585
+ const displayId = connection?.deviceId || connectionId;
586
+ console.log(`📦 ${t('connection.chunkingMessage', { event, chunks: chunks.length, size: Math.round(chunks.length * 800 / 1024), deviceId: displayId })}`);
587
+ // Send each chunk as an 'rpc' event with special __chunk marker
588
+ // This ensures chunks are routed correctly through the proxy-server
589
+ for (const chunk of chunks) {
590
+ this.socket.emit('rpc', {
591
+ connectionId,
592
+ data: {
593
+ __chunk: true,
594
+ ...chunk,
595
+ },
596
+ });
597
+ }
598
+ }
599
+ else {
600
+ // Send normally for small messages
601
+ this.socket.emit(event, {
602
+ connectionId,
603
+ data,
604
+ });
605
+ }
606
+ }
607
+ /**
608
+ * Compute HMAC for message verification
609
+ */
610
+ computeHMAC(message, secret) {
611
+ const { timestamp, ...rest } = message;
612
+ const messageToSign = timestamp + JSON.stringify(rest);
613
+ return crypto.createHmac('sha256', secret).update(messageToSign).digest('hex');
614
+ }
615
+ /**
616
+ * Handle client disconnected event
617
+ */
618
+ handleClientDisconnected(data) {
619
+ const connection = this.activeConnections.get(data.connectionId);
620
+ logConnection('disconnected', connection?.deviceId, {
621
+ connectionId: data.connectionId
622
+ });
623
+ if (data.reason === 'daily_limit_exceeded') {
624
+ console.warn(`\n⚠️ ${t('proxyError.dailyLimitExceeded')}`);
625
+ console.warn(`${t('proxyError.dailyLimitReset', { time: formatTimeUntilReset(data.resetTime) })}`);
626
+ console.warn(`${t('proxyError.dailyLimitExceededHint')}\n`);
627
+ }
628
+ // Clean up connection tracking
629
+ this.activeConnections.delete(data.connectionId);
630
+ }
631
+ /**
632
+ * Handle proxy error
633
+ */
634
+ async handleError(error) {
635
+ // Special handling for expired_firebase_token - attempt refresh before giving up
636
+ if (error.code === 'expired_firebase_token' && !this.tokenRefreshAttempted) {
637
+ console.warn(`\n⚠️ ${t('proxyError.firebaseTokenExpiring')}`);
638
+ this.tokenRefreshAttempted = true;
639
+ try {
640
+ await this.refreshTokenAndReconnect();
641
+ // If successful, the connection will be re-established
642
+ return;
643
+ }
644
+ catch (refreshError) {
645
+ console.error(`\n❌ ${t('proxyError.tokenRefreshFailed', { message: refreshError.message })}`);
646
+ // Fall through to regular error handling
647
+ }
648
+ }
649
+ // Regular error handling
650
+ console.error(`\n❌ ${t('proxyError.error', { message: error.message })}`);
651
+ switch (error.code) {
652
+ case 'subscription_error_4020':
653
+ console.error(`\n⚠️ ${t('proxyError.tokenExpiredTimeout')}`);
654
+ console.error(`${t('proxyError.tokenExpiredHint')}\n`);
655
+ break;
656
+ case 'subscription_error_4021':
657
+ console.error(`\n⚠️ ${t('proxyError.tokenRevoked')}`);
658
+ console.error(`${t('proxyError.tokenRevokedHint')}\n`);
659
+ break;
660
+ case 'subscription_error_9996':
661
+ console.error(`\n⚠️ ${t('proxyError.privacyConsent')}`);
662
+ console.error(t('proxyError.privacyConsentHint1'));
663
+ console.error(`${t('proxyError.privacyConsentHint2')}\n`);
664
+ break;
665
+ case 'subscription_error_9997':
666
+ console.error(`\n⚠️ ${t('proxyError.accountDeleting')}`);
667
+ console.error(`${t('proxyError.accountDeletingHint')}\n`);
668
+ break;
669
+ case 'subscription_error_9998':
670
+ console.error(`\n⛔ ${t('proxyError.accountBanned')}`);
671
+ console.error(t('proxyError.accountBannedHint1'));
672
+ console.error(`${t('proxyError.accountBannedHint2')}\n`);
673
+ break;
674
+ case 'subscription_check_failed':
675
+ console.error(`\n⚠️ ${t('proxyError.subscriptionCheckFailed')}`);
676
+ console.error(`${t('proxyError.subscriptionCheckFailedHint')}\n`);
677
+ break;
678
+ case 'subscription_required':
679
+ console.error(`\n⚠️ ${t('proxyError.subscriptionRequired')}`);
680
+ console.error(`${t('proxyError.subscriptionRequiredHint')}\n`);
681
+ break;
682
+ case 'daily_limit_exceeded':
683
+ console.error(`\n⚠️ ${t('proxyError.dailyLimitExceeded')}`);
684
+ console.error(`${t('proxyError.dailyLimitReset', { time: formatTimeUntilReset(error.resetTime) })}`);
685
+ console.error(`${t('proxyError.dailyLimitExceededHint')}\n`);
686
+ break;
687
+ case 'max_connections_reached': {
688
+ const maxConnections = error.maxConnections || 5;
689
+ console.error(`\n⚠️ ${t('proxyError.maxConnections', { max: maxConnections })}`);
690
+ console.error(`${t('proxyError.maxConnectionsHint')}\n`);
691
+ break;
692
+ }
693
+ case 'duplicate_client_id':
694
+ console.error(`\n⚠️ ${t('proxyError.duplicateClientId')}`);
695
+ console.error(t('proxyError.duplicateHint1'));
696
+ console.error(` ${t('proxyError.duplicateHint2')}`);
697
+ console.error(` ${t('proxyError.duplicateHint3')}`);
698
+ console.error(`\n${t('proxyError.duplicateHint4')}`);
699
+ console.error(` ${t('proxyError.duplicateHint5')}`);
700
+ console.error(` ${t('proxyError.duplicateHint6')}`);
701
+ console.error(` ${t('proxyError.duplicateHint7')}\n`);
702
+ break;
703
+ case 'expired_firebase_token':
704
+ if (this.tokenRefreshAttempted) {
705
+ console.error(`\n⚠️ ${t('proxyError.firebaseExpiredRefreshFailed')}`);
706
+ console.error(`${t('proxyError.firebaseExpiredRefreshFailedHint')}\n`);
707
+ }
708
+ else {
709
+ console.error(`\n⚠️ ${t('proxyError.firebaseExpired')}`);
710
+ console.error(`${t('proxyError.firebaseExpiredHint')}\n`);
711
+ }
712
+ break;
713
+ case 'invalid_firebase_token':
714
+ console.error(`\n⚠️ ${t('proxyError.firebaseInvalid')}`);
715
+ console.error(t('proxyError.firebaseInvalidHint1'));
716
+ console.error(`${t('proxyError.firebaseInvalidHint2')}\n`);
717
+ break;
718
+ default:
719
+ console.error(`\n⚠️ ${t('proxyError.defaultError')}`);
720
+ console.error(error.message);
721
+ break;
722
+ }
723
+ process.exit(1);
724
+ }
725
+ /**
726
+ * Handle disconnect from proxy
727
+ */
728
+ handleDisconnect(reason) {
729
+ console.warn(`\n⚠️ ${t('connection.disconnectedFromProxy', { reason })}`);
730
+ if (reason === 'io server disconnect') {
731
+ // Server forcefully disconnected us
732
+ console.error(`${t('connection.serverTerminated')}\n`);
733
+ process.exit(1);
734
+ }
735
+ // Socket.IO will auto-reconnect
736
+ console.log(t('connection.attemptingReconnect'));
737
+ }
738
+ /**
739
+ * Handle reconnection attempt
740
+ */
741
+ handleReconnectAttempt(attemptNumber) {
742
+ console.log(`🔄 ${t('connection.reconnectAttempt', { attempt: attemptNumber })}`);
743
+ }
744
+ /**
745
+ * Handle successful reconnection
746
+ */
747
+ handleReconnect(attemptNumber) {
748
+ console.log(`\n✅ ${t('connection.reconnected', { attempts: attemptNumber })}\n`);
749
+ }
750
+ /**
751
+ * Handle reconnection failure
752
+ */
753
+ handleReconnectFailed() {
754
+ console.error(`\n❌ ${t('connection.reconnectFailed')}`);
755
+ console.error(`${t('connection.exiting')}\n`);
756
+ process.exit(1);
757
+ }
758
+ /**
759
+ * Graceful disconnect from proxy
760
+ */
761
+ async disconnect() {
762
+ if (!this.socket)
763
+ return;
764
+ console.log(`\n🛑 ${t('connection.shuttingDown')}`);
765
+ return new Promise((resolve) => {
766
+ const timeout = setTimeout(() => {
767
+ console.warn(`⚠️ ${t('connection.killTimeout')}`);
768
+ this.socket?.disconnect();
769
+ resolve();
770
+ }, KILL_TIMEOUT);
771
+ this.socket.once('killed', () => {
772
+ clearTimeout(timeout);
773
+ console.log(`✅ ${t('connection.gracefulDisconnect')}`);
774
+ this.socket?.disconnect();
775
+ resolve();
776
+ });
777
+ this.socket.emit('kill');
778
+ });
779
+ }
780
+ }
781
+ //# sourceMappingURL=ProxyClient.js.map