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