spck 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.oxlintrc.json +49 -0
- package/LICENSE +21 -0
- package/README.md +631 -0
- package/bin/cli.js +20 -0
- package/bin/validate-cwd.js +41 -0
- package/dist/config/__tests__/config.test.d.ts +2 -0
- package/dist/config/__tests__/config.test.js +262 -0
- package/dist/config/__tests__/credentials.test.d.ts +2 -0
- package/dist/config/__tests__/credentials.test.js +360 -0
- package/dist/config/config.d.ts +33 -0
- package/dist/config/config.js +185 -0
- package/dist/config/credentials.d.ts +75 -0
- package/dist/config/credentials.js +259 -0
- package/dist/config/server-selection.d.ts +40 -0
- package/dist/config/server-selection.js +130 -0
- package/dist/connection/__tests__/firebase-auth.test.d.ts +2 -0
- package/dist/connection/__tests__/firebase-auth.test.js +96 -0
- package/dist/connection/__tests__/hmac.test.d.ts +2 -0
- package/dist/connection/__tests__/hmac.test.js +372 -0
- package/dist/connection/auth.d.ts +13 -0
- package/dist/connection/auth.js +91 -0
- package/dist/connection/firebase-auth.d.ts +40 -0
- package/dist/connection/firebase-auth.js +429 -0
- package/dist/connection/hmac.d.ts +24 -0
- package/dist/connection/hmac.js +109 -0
- package/dist/i18n/index.d.ts +25 -0
- package/dist/i18n/index.js +101 -0
- package/dist/i18n/locales/en.json +313 -0
- package/dist/i18n/locales/es.json +302 -0
- package/dist/i18n/locales/fr.json +302 -0
- package/dist/i18n/locales/id.json +302 -0
- package/dist/i18n/locales/ja.json +302 -0
- package/dist/i18n/locales/ko.json +302 -0
- package/dist/i18n/locales/locales/en.json +309 -0
- package/dist/i18n/locales/locales/es.json +302 -0
- package/dist/i18n/locales/locales/fr.json +302 -0
- package/dist/i18n/locales/locales/id.json +302 -0
- package/dist/i18n/locales/locales/ja.json +302 -0
- package/dist/i18n/locales/locales/ko.json +302 -0
- package/dist/i18n/locales/locales/pt.json +302 -0
- package/dist/i18n/locales/locales/zh-Hans.json +302 -0
- package/dist/i18n/locales/pt.json +302 -0
- package/dist/i18n/locales/zh-Hans.json +302 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.js +493 -0
- package/dist/proxy/ProxyClient.d.ts +125 -0
- package/dist/proxy/ProxyClient.js +781 -0
- package/dist/proxy/ProxySocketWrapper.d.ts +43 -0
- package/dist/proxy/ProxySocketWrapper.js +98 -0
- package/dist/proxy/__tests__/ProxyClient.test.d.ts +2 -0
- package/dist/proxy/__tests__/ProxyClient.test.js +445 -0
- package/dist/proxy/__tests__/ProxySocketWrapper.test.d.ts +2 -0
- package/dist/proxy/__tests__/ProxySocketWrapper.test.js +190 -0
- package/dist/proxy/__tests__/handshake-validation.test.d.ts +2 -0
- package/dist/proxy/__tests__/handshake-validation.test.js +282 -0
- package/dist/proxy/__tests__/token-refresh-race.test.d.ts +14 -0
- package/dist/proxy/__tests__/token-refresh-race.test.js +173 -0
- package/dist/proxy/chunking.d.ts +53 -0
- package/dist/proxy/chunking.js +127 -0
- package/dist/proxy/handshake-validation.d.ts +21 -0
- package/dist/proxy/handshake-validation.js +49 -0
- package/dist/rpc/__tests__/router.test.d.ts +2 -0
- package/dist/rpc/__tests__/router.test.js +262 -0
- package/dist/rpc/router.d.ts +37 -0
- package/dist/rpc/router.js +132 -0
- package/dist/services/BrowserProxyService.d.ts +13 -0
- package/dist/services/BrowserProxyService.js +139 -0
- package/dist/services/FilesystemService.d.ts +99 -0
- package/dist/services/FilesystemService.js +742 -0
- package/dist/services/GitService.d.ts +243 -0
- package/dist/services/GitService.js +1439 -0
- package/dist/services/SearchService.d.ts +93 -0
- package/dist/services/SearchService.js +670 -0
- package/dist/services/TerminalService.d.ts +62 -0
- package/dist/services/TerminalService.js +337 -0
- package/dist/services/__tests__/BrowserProxyService.test.d.ts +2 -0
- package/dist/services/__tests__/BrowserProxyService.test.js +145 -0
- package/dist/services/__tests__/FilesystemService.test.d.ts +2 -0
- package/dist/services/__tests__/FilesystemService.test.js +609 -0
- package/dist/services/__tests__/GitService.test.d.ts +2 -0
- package/dist/services/__tests__/GitService.test.js +953 -0
- package/dist/services/__tests__/SearchService.test.d.ts +2 -0
- package/dist/services/__tests__/SearchService.test.js +384 -0
- package/dist/services/__tests__/TerminalService.test.d.ts +2 -0
- package/dist/services/__tests__/TerminalService.test.js +513 -0
- package/dist/setup/wizard.d.ts +10 -0
- package/dist/setup/wizard.js +172 -0
- package/dist/types.d.ts +196 -0
- package/dist/types.js +44 -0
- package/dist/utils/__tests__/gitignore.test.d.ts +2 -0
- package/dist/utils/__tests__/gitignore.test.js +127 -0
- package/dist/utils/gitignore.d.ts +24 -0
- package/dist/utils/gitignore.js +77 -0
- package/dist/utils/logger.d.ts +96 -0
- package/dist/utils/logger.js +456 -0
- package/dist/utils/project-dir.d.ts +51 -0
- package/dist/utils/project-dir.js +191 -0
- package/dist/utils/ripgrep.d.ts +34 -0
- package/dist/utils/ripgrep.js +148 -0
- package/dist/utils/tool-detection.d.ts +17 -0
- package/dist/utils/tool-detection.js +126 -0
- package/dist/watcher/FileWatcher.d.ts +10 -0
- package/dist/watcher/FileWatcher.js +42 -0
- package/package.json +70 -0
- package/src/config/__tests__/config.test.ts +318 -0
- package/src/config/__tests__/credentials.test.ts +494 -0
- package/src/config/config.ts +206 -0
- package/src/config/credentials.ts +302 -0
- package/src/config/server-selection.ts +150 -0
- package/src/connection/__tests__/firebase-auth.test.ts +121 -0
- package/src/connection/__tests__/hmac.test.ts +509 -0
- package/src/connection/auth.ts +140 -0
- package/src/connection/firebase-auth.ts +504 -0
- package/src/connection/hmac.ts +139 -0
- package/src/i18n/index.ts +119 -0
- package/src/i18n/locales/en.json +313 -0
- package/src/i18n/locales/es.json +302 -0
- package/src/i18n/locales/fr.json +302 -0
- package/src/i18n/locales/id.json +302 -0
- package/src/i18n/locales/ja.json +302 -0
- package/src/i18n/locales/ko.json +302 -0
- package/src/i18n/locales/pt.json +302 -0
- package/src/i18n/locales/zh-Hans.json +302 -0
- package/src/index.ts +542 -0
- package/src/proxy/ProxyClient.ts +968 -0
- package/src/proxy/ProxySocketWrapper.ts +113 -0
- package/src/proxy/__tests__/ProxyClient.test.ts +575 -0
- package/src/proxy/__tests__/ProxySocketWrapper.test.ts +251 -0
- package/src/proxy/__tests__/handshake-validation.test.ts +367 -0
- package/src/proxy/chunking.ts +162 -0
- package/src/proxy/handshake-validation.ts +64 -0
- package/src/rpc/__tests__/router.test.ts +400 -0
- package/src/rpc/router.ts +183 -0
- package/src/services/BrowserProxyService.ts +179 -0
- package/src/services/FilesystemService.ts +841 -0
- package/src/services/GitService.ts +1639 -0
- package/src/services/SearchService.ts +809 -0
- package/src/services/TerminalService.ts +413 -0
- package/src/services/__tests__/BrowserProxyService.test.ts +155 -0
- package/src/services/__tests__/FilesystemService.test.ts +1002 -0
- package/src/services/__tests__/GitService.test.ts +1552 -0
- package/src/services/__tests__/SearchService.test.ts +484 -0
- package/src/services/__tests__/TerminalService.test.ts +702 -0
- package/src/setup/wizard.ts +242 -0
- package/src/types/fossil-delta.d.ts +4 -0
- package/src/types.ts +287 -0
- package/src/utils/__tests__/gitignore.test.ts +174 -0
- package/src/utils/gitignore.ts +91 -0
- package/src/utils/logger.ts +578 -0
- package/src/utils/project-dir.ts +218 -0
- package/src/utils/ripgrep.ts +180 -0
- package/src/utils/tool-detection.ts +141 -0
- package/src/watcher/FileWatcher.ts +53 -0
- package/tsconfig.json +24 -0
- package/vitest.config.ts +19 -0
|
@@ -0,0 +1,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
|
+
}
|