kiro-mobile-bridge 1.0.21 → 1.0.23
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/README.md +44 -11
- package/package.json +1 -1
- package/src/middleware/auth.js +515 -0
- package/src/public/index.html +20 -0
- package/src/server.js +114 -34
- package/src/utils/constants.js +6 -0
package/README.md
CHANGED
|
@@ -8,6 +8,7 @@ A lightweight mobile interface that lets you monitor and control Kiro IDE agent
|
|
|
8
8
|
## Features
|
|
9
9
|
|
|
10
10
|
- 📱 Mobile-optimized web interface with tab navigation
|
|
11
|
+
- 🔑 **OTP Authentication** - 6-digit access code generated on server startup
|
|
11
12
|
- 💬 **Chat** - View and send messages to Kiro's agent
|
|
12
13
|
- 📝 **Code** - Browse file explorer and view files with syntax highlighting
|
|
13
14
|
- 📋 **Tasks** - View and navigate Kiro spec task files
|
|
@@ -24,11 +25,13 @@ A lightweight mobile interface that lets you monitor and control Kiro IDE agent
|
|
|
24
25
|
|
|
25
26
|
Start Kiro with the remote debugging port enabled:
|
|
26
27
|
|
|
27
|
-
**Run Kiro with debugging port on
|
|
28
|
+
**Run Kiro with debugging port on CMD/Terminal:**
|
|
28
29
|
```bash
|
|
29
30
|
kiro --remote-debugging-port=9000
|
|
30
31
|
```
|
|
31
32
|
|
|
33
|
+
**Important:** Your project must be open in Kiro before you close it - the bridge needs an active session to detect and connect to. After that, start Kiro from the terminal with the remote debugging port enabled.
|
|
34
|
+
|
|
32
35
|
### 2. Run with npx (Recommended)
|
|
33
36
|
|
|
34
37
|
Start Server
|
|
@@ -53,15 +56,29 @@ Kiro Mobile Bridge
|
|
|
53
56
|
─────────────────────
|
|
54
57
|
Local: http://localhost:3000
|
|
55
58
|
Network: http://192.168.16.106:3000
|
|
56
|
-
|
|
59
|
+
|
|
60
|
+
🔑 Access Code: 847291
|
|
61
|
+
|
|
62
|
+
Enter this code on your device to connect.
|
|
57
63
|
```
|
|
58
64
|
|
|
59
65
|
### 3. Open on Your Phone
|
|
60
66
|
|
|
61
67
|
1. Make sure your phone is on the **same WiFi network** as your computer
|
|
62
68
|
2. Open the **Network URL** (e.g., `http://192.168.1.100:3000`) in your phone's browser
|
|
63
|
-
3.
|
|
64
|
-
4.
|
|
69
|
+
3. Enter the **6-digit access code** shown in the terminal
|
|
70
|
+
4. The interface will connect and show your Kiro session
|
|
71
|
+
5. Use the tabs to switch between Chat, Code, and Tasks panels
|
|
72
|
+
|
|
73
|
+
> **Note:** The access code is single-use — only one device can authenticate per server session. Restart the server to generate a new code.
|
|
74
|
+
|
|
75
|
+
#### Disable Authentication
|
|
76
|
+
|
|
77
|
+
For trusted environments where you want the original no-auth experience:
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
npx kiro-mobile-bridge --no-auth
|
|
81
|
+
```
|
|
65
82
|
|
|
66
83
|
|
|
67
84
|
#### How It Works
|
|
@@ -99,6 +116,23 @@ Open the Network URL on your phone to monitor Kiro.
|
|
|
99
116
|
- Check your firewall allows connections on port 3000
|
|
100
117
|
- Try the IP address shown in the server output (not `localhost`)
|
|
101
118
|
|
|
119
|
+
#### Windows: Works on your computer but not on mobile, even on same WiFi.
|
|
120
|
+
|
|
121
|
+
**Root Cause:** Node.js firewall rule only allows **Public** networks by default. If your network is set to **Private**, mobile devices can't connect.
|
|
122
|
+
|
|
123
|
+
**Quick Fix - Option 1: Change Network to Public (Easiest)**
|
|
124
|
+
1. Open **Settings** → **Network & Internet**
|
|
125
|
+
2. Click your connection (WiFi or Ethernet)
|
|
126
|
+
3. Under "Network profile type", select **Public network (Recommended)**
|
|
127
|
+
4. Try accessing from mobile again
|
|
128
|
+
|
|
129
|
+
**Quick Fix - Option 2: Update Firewall Rule (Better for home networks)**
|
|
130
|
+
|
|
131
|
+
Run this command **as Administrator** (Win + X → Terminal Admin):
|
|
132
|
+
```cmd
|
|
133
|
+
netsh advfirewall firewall set rule name="Node.js JavaScript Runtime" new profile=private,public
|
|
134
|
+
```
|
|
135
|
+
|
|
102
136
|
#### Linux: Firewall blocking connections
|
|
103
137
|
|
|
104
138
|
If you're on Linux and can't connect from your phone, your firewall may be blocking port 3000. Allow it with:
|
|
@@ -113,13 +147,12 @@ sudo iptables -A INPUT -p tcp --dport 3000 -j ACCEPT
|
|
|
113
147
|
|
|
114
148
|
## Security Notes
|
|
115
149
|
|
|
116
|
-
####
|
|
117
|
-
**
|
|
118
|
-
|
|
119
|
-
-
|
|
120
|
-
-
|
|
121
|
-
-
|
|
122
|
-
|
|
150
|
+
#### OTP Authentication
|
|
151
|
+
- A **6-digit access code** is generated on each server startup and displayed in the terminal
|
|
152
|
+
- The code is **single-use** — once a device authenticates, the code is consumed and all other devices are immediately locked out
|
|
153
|
+
- New devices opening the login page during lockout or after the code is consumed will see a locked UI immediately
|
|
154
|
+
- Sessions use **HttpOnly cookies** — tokens are not exposed to client-side JavaScript
|
|
155
|
+
- Use `--no-auth` to disable authentication for fully trusted environments
|
|
123
156
|
|
|
124
157
|
## License
|
|
125
158
|
|
package/package.json
CHANGED
|
@@ -0,0 +1,515 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OTP Authentication Middleware
|
|
3
|
+
* Generates a single-use 6-digit OTP on server startup.
|
|
4
|
+
* After successful verification, issues an HttpOnly session cookie.
|
|
5
|
+
* Single-device only — no regeneration until server restart.
|
|
6
|
+
*
|
|
7
|
+
* NOTE: crypto.randomInt is used for OTP generation (cryptographically secure).
|
|
8
|
+
* crypto.randomBytes is used for session tokens (cryptographically secure).
|
|
9
|
+
*/
|
|
10
|
+
import crypto from 'crypto';
|
|
11
|
+
import { OTP_MAX_ATTEMPTS, OTP_LOCKOUT_MS } from '../utils/constants.js';
|
|
12
|
+
|
|
13
|
+
// =============================================================================
|
|
14
|
+
// State
|
|
15
|
+
// =============================================================================
|
|
16
|
+
|
|
17
|
+
/** @type {{ otp: string, consumed: boolean, sessionToken: string|null }} */
|
|
18
|
+
const authState = {
|
|
19
|
+
otp: '',
|
|
20
|
+
consumed: false,
|
|
21
|
+
sessionToken: null
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/** @type {{ attempts: number, lockedUntil: number }} */
|
|
25
|
+
const rateLimitState = {
|
|
26
|
+
attempts: 0,
|
|
27
|
+
lockedUntil: 0
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/** @type {boolean} */
|
|
31
|
+
let authEnabled = true;
|
|
32
|
+
|
|
33
|
+
// =============================================================================
|
|
34
|
+
// OTP Generation & Verification
|
|
35
|
+
// =============================================================================
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Generate a new 6-digit OTP (cryptographically random)
|
|
39
|
+
* Called once on server startup
|
|
40
|
+
* @returns {string} 6-digit OTP
|
|
41
|
+
*/
|
|
42
|
+
export function generateOTP() {
|
|
43
|
+
// crypto.randomInt generates a cryptographically secure random integer
|
|
44
|
+
const code = crypto.randomInt(100000, 999999 + 1);
|
|
45
|
+
authState.otp = String(code);
|
|
46
|
+
authState.consumed = false;
|
|
47
|
+
authState.sessionToken = null;
|
|
48
|
+
rateLimitState.attempts = 0;
|
|
49
|
+
rateLimitState.lockedUntil = 0;
|
|
50
|
+
return authState.otp;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Get the current OTP (for terminal display)
|
|
55
|
+
* @returns {string}
|
|
56
|
+
*/
|
|
57
|
+
export function getOTP() {
|
|
58
|
+
return authState.otp;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Set whether auth is enabled
|
|
63
|
+
* @param {boolean} enabled
|
|
64
|
+
*/
|
|
65
|
+
export function setAuthEnabled(enabled) {
|
|
66
|
+
authEnabled = enabled;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Check if auth is enabled
|
|
71
|
+
* @returns {boolean}
|
|
72
|
+
*/
|
|
73
|
+
export function isAuthEnabled() {
|
|
74
|
+
return authEnabled;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Verify OTP code
|
|
79
|
+
* Returns session token on success, null on failure.
|
|
80
|
+
* OTP is single-use — once consumed, further attempts are rejected.
|
|
81
|
+
*
|
|
82
|
+
* @param {string} code - The 6-digit OTP to verify
|
|
83
|
+
* @returns {{ success: boolean, token?: string, error?: string, retryAfter?: number }}
|
|
84
|
+
*/
|
|
85
|
+
export function verifyOTP(code) {
|
|
86
|
+
const now = Date.now();
|
|
87
|
+
|
|
88
|
+
// Check rate limit lockout
|
|
89
|
+
if (rateLimitState.lockedUntil > now) {
|
|
90
|
+
const retryAfter = Math.ceil((rateLimitState.lockedUntil - now) / 1000);
|
|
91
|
+
return {
|
|
92
|
+
success: false,
|
|
93
|
+
error: `Too many attempts. Try again in ${retryAfter}s.`,
|
|
94
|
+
retryAfter
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Reset attempt counter once lockout period has expired
|
|
99
|
+
if (rateLimitState.lockedUntil > 0 && rateLimitState.lockedUntil <= now) {
|
|
100
|
+
rateLimitState.attempts = 0;
|
|
101
|
+
rateLimitState.lockedUntil = 0;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// OTP already consumed — no new sessions allowed
|
|
105
|
+
// Still enforce rate limiting to prevent brute-force probing
|
|
106
|
+
if (authState.consumed) {
|
|
107
|
+
rateLimitState.attempts++;
|
|
108
|
+
if (rateLimitState.attempts >= OTP_MAX_ATTEMPTS) {
|
|
109
|
+
rateLimitState.lockedUntil = now + OTP_LOCKOUT_MS;
|
|
110
|
+
return {
|
|
111
|
+
success: false,
|
|
112
|
+
consumed: true,
|
|
113
|
+
error: `Too many attempts. Try again in ${OTP_LOCKOUT_MS / 1000}s.`,
|
|
114
|
+
retryAfter: OTP_LOCKOUT_MS / 1000
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
return {
|
|
118
|
+
success: false,
|
|
119
|
+
consumed: true,
|
|
120
|
+
error: 'Access code already used. Restart the server for a new code.'
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Validate input format
|
|
125
|
+
if (!code || typeof code !== 'string' || !/^\d{6}$/.test(code)) {
|
|
126
|
+
rateLimitState.attempts++;
|
|
127
|
+
if (rateLimitState.attempts >= OTP_MAX_ATTEMPTS) {
|
|
128
|
+
rateLimitState.lockedUntil = now + OTP_LOCKOUT_MS;
|
|
129
|
+
return {
|
|
130
|
+
success: false,
|
|
131
|
+
error: `Too many attempts. Try again in ${OTP_LOCKOUT_MS / 1000}s.`,
|
|
132
|
+
retryAfter: OTP_LOCKOUT_MS / 1000
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
return { success: false, error: 'Invalid code format.' };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Timing-safe comparison to prevent timing attacks
|
|
139
|
+
const codeBuffer = Buffer.from(code);
|
|
140
|
+
const otpBuffer = Buffer.from(authState.otp);
|
|
141
|
+
if (codeBuffer.length !== otpBuffer.length || !crypto.timingSafeEqual(codeBuffer, otpBuffer)) {
|
|
142
|
+
rateLimitState.attempts++;
|
|
143
|
+
if (rateLimitState.attempts >= OTP_MAX_ATTEMPTS) {
|
|
144
|
+
rateLimitState.lockedUntil = now + OTP_LOCKOUT_MS;
|
|
145
|
+
return {
|
|
146
|
+
success: false,
|
|
147
|
+
error: `Too many attempts. Try again in ${OTP_LOCKOUT_MS / 1000}s.`,
|
|
148
|
+
retryAfter: OTP_LOCKOUT_MS / 1000
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
return {
|
|
152
|
+
success: false,
|
|
153
|
+
error: `Invalid code. ${OTP_MAX_ATTEMPTS - rateLimitState.attempts} attempts remaining.`
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Success — mark consumed, generate session token
|
|
158
|
+
authState.consumed = true;
|
|
159
|
+
authState.sessionToken = crypto.randomBytes(32).toString('hex');
|
|
160
|
+
rateLimitState.attempts = 0;
|
|
161
|
+
|
|
162
|
+
return { success: true, token: authState.sessionToken };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Get current rate limit status (for exposing via API)
|
|
167
|
+
* @returns {{ locked: boolean, retryAfter: number }}
|
|
168
|
+
*/
|
|
169
|
+
export function getRateLimitStatus() {
|
|
170
|
+
const now = Date.now();
|
|
171
|
+
if (rateLimitState.lockedUntil > now) {
|
|
172
|
+
return { locked: true, consumed: authState.consumed, retryAfter: Math.ceil((rateLimitState.lockedUntil - now) / 1000) };
|
|
173
|
+
}
|
|
174
|
+
return { locked: false, consumed: authState.consumed, retryAfter: 0 };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Validate a session token
|
|
179
|
+
* @param {string} token - Session token to validate
|
|
180
|
+
* @returns {boolean}
|
|
181
|
+
*/
|
|
182
|
+
export function validateSession(token) {
|
|
183
|
+
if (!token || typeof token !== 'string') return false;
|
|
184
|
+
if (!authState.sessionToken) return false;
|
|
185
|
+
|
|
186
|
+
// Timing-safe comparison
|
|
187
|
+
const tokenBuffer = Buffer.from(token);
|
|
188
|
+
const sessionBuffer = Buffer.from(authState.sessionToken);
|
|
189
|
+
if (tokenBuffer.length !== sessionBuffer.length) return false;
|
|
190
|
+
return crypto.timingSafeEqual(tokenBuffer, sessionBuffer);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// =============================================================================
|
|
194
|
+
// Cookie Parsing Helper
|
|
195
|
+
// =============================================================================
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Parse session token from cookie header
|
|
199
|
+
* @param {string} cookieHeader - Raw Cookie header value
|
|
200
|
+
* @returns {string|null}
|
|
201
|
+
*/
|
|
202
|
+
function parseSessionCookie(cookieHeader) {
|
|
203
|
+
if (!cookieHeader || typeof cookieHeader !== 'string') return null;
|
|
204
|
+
const match = cookieHeader.match(/(?:^|;\s*)kmb_session=([a-f0-9]{64})(?:;|$)/);
|
|
205
|
+
return match ? match[1] : null;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// =============================================================================
|
|
209
|
+
// Express Middleware
|
|
210
|
+
// =============================================================================
|
|
211
|
+
|
|
212
|
+
/** Routes that bypass authentication */
|
|
213
|
+
const PUBLIC_PATHS = new Set(['/auth/login', '/auth/verify', '/auth/status']);
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Express authentication middleware
|
|
217
|
+
* Checks for valid session cookie on all requests except auth routes.
|
|
218
|
+
* Redirects page requests to login, returns 401 for API/fetch requests.
|
|
219
|
+
*/
|
|
220
|
+
export function authMiddleware(req, res, next) {
|
|
221
|
+
// Skip auth entirely if disabled (--no-auth flag)
|
|
222
|
+
if (!authEnabled) return next();
|
|
223
|
+
|
|
224
|
+
// Allow public auth routes
|
|
225
|
+
if (PUBLIC_PATHS.has(req.path)) return next();
|
|
226
|
+
|
|
227
|
+
// Parse session token from cookie
|
|
228
|
+
const token = parseSessionCookie(req.headers.cookie);
|
|
229
|
+
|
|
230
|
+
if (token && validateSession(token)) {
|
|
231
|
+
return next();
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Not authenticated — determine response type
|
|
235
|
+
const wantsJSON = req.headers.accept?.includes('application/json') ||
|
|
236
|
+
req.headers['content-type']?.includes('application/json') ||
|
|
237
|
+
req.xhr;
|
|
238
|
+
|
|
239
|
+
if (wantsJSON) {
|
|
240
|
+
return res.status(401).json({ error: 'Authentication required' });
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Redirect browser requests to login page
|
|
244
|
+
return res.redirect('/auth/login');
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// =============================================================================
|
|
248
|
+
// WebSocket Auth
|
|
249
|
+
// =============================================================================
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Validate WebSocket connection authentication
|
|
253
|
+
* Checks session token from the cookie header on the upgrade request.
|
|
254
|
+
* Browsers automatically send cookies with WebSocket upgrade requests,
|
|
255
|
+
* so we don't need to pass the token via query string.
|
|
256
|
+
*
|
|
257
|
+
* @param {import('http').IncomingMessage} req - Upgrade request
|
|
258
|
+
* @returns {boolean}
|
|
259
|
+
*/
|
|
260
|
+
export function validateWSAuth(req) {
|
|
261
|
+
if (!authEnabled) return true;
|
|
262
|
+
|
|
263
|
+
try {
|
|
264
|
+
const cookie = req.headers.cookie || '';
|
|
265
|
+
const match = cookie.match(/(?:^|;\s*)kmb_session=([a-f0-9]{64})(?:;|$)/);
|
|
266
|
+
const token = match ? match[1] : null;
|
|
267
|
+
return validateSession(token);
|
|
268
|
+
} catch {
|
|
269
|
+
return false;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// =============================================================================
|
|
274
|
+
// Login Page HTML
|
|
275
|
+
// =============================================================================
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Generate the self-contained login page HTML
|
|
279
|
+
* Matches the dark theme of the main interface
|
|
280
|
+
* @returns {string}
|
|
281
|
+
*/
|
|
282
|
+
export function getLoginPageHTML() {
|
|
283
|
+
return `<!DOCTYPE html>
|
|
284
|
+
<html lang="en">
|
|
285
|
+
<head>
|
|
286
|
+
<meta charset="UTF-8">
|
|
287
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
|
288
|
+
<meta name="theme-color" content="#1e1e1e">
|
|
289
|
+
<meta name="apple-mobile-web-app-capable" content="yes">
|
|
290
|
+
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
|
291
|
+
<title>Kiro Mobile Bridge — Access Code</title>
|
|
292
|
+
<style>
|
|
293
|
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
294
|
+
html, body {
|
|
295
|
+
height: 100%; overflow: hidden; background: #1e1e1e;
|
|
296
|
+
font-family: "Segoe WPC", "Segoe UI", -apple-system, BlinkMacSystemFont, system-ui, Ubuntu, sans-serif;
|
|
297
|
+
color: #cccccc;
|
|
298
|
+
}
|
|
299
|
+
.login-container {
|
|
300
|
+
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
|
301
|
+
height: 100%; height: 100dvh; padding: 24px; text-align: center;
|
|
302
|
+
}
|
|
303
|
+
.logo { font-size: 28px; font-weight: 600; color: #ffffff; margin-bottom: 8px; }
|
|
304
|
+
.subtitle { font-size: 13px; color: #888; margin-bottom: 40px; }
|
|
305
|
+
.otp-label { font-size: 14px; color: #cccccc; margin-bottom: 16px; }
|
|
306
|
+
.otp-inputs {
|
|
307
|
+
display: flex; gap: 8px; margin-bottom: 24px; justify-content: center;
|
|
308
|
+
}
|
|
309
|
+
.otp-inputs input {
|
|
310
|
+
width: 48px; height: 56px; text-align: center; font-size: 24px; font-weight: 600;
|
|
311
|
+
background: #2d2d2d; border: 2px solid #3c3c3c; border-radius: 8px; color: #ffffff;
|
|
312
|
+
outline: none; caret-color: #0078d4; transition: border-color 0.15s;
|
|
313
|
+
-webkit-appearance: none; appearance: none;
|
|
314
|
+
}
|
|
315
|
+
.otp-inputs input:focus { border-color: #0078d4; }
|
|
316
|
+
.otp-inputs input.error { border-color: #f44336; animation: shake 0.4s; }
|
|
317
|
+
.otp-inputs input.success { border-color: #4caf50; }
|
|
318
|
+
.error-message {
|
|
319
|
+
color: #f44336; font-size: 13px; min-height: 20px; margin-bottom: 16px;
|
|
320
|
+
transition: opacity 0.2s;
|
|
321
|
+
}
|
|
322
|
+
.status-message {
|
|
323
|
+
color: #4caf50; font-size: 13px; min-height: 20px; margin-bottom: 16px;
|
|
324
|
+
}
|
|
325
|
+
.hint {
|
|
326
|
+
font-size: 12px; color: #666; max-width: 280px; line-height: 1.5;
|
|
327
|
+
}
|
|
328
|
+
@keyframes shake {
|
|
329
|
+
0%, 100% { transform: translateX(0); }
|
|
330
|
+
25% { transform: translateX(-6px); }
|
|
331
|
+
50% { transform: translateX(6px); }
|
|
332
|
+
75% { transform: translateX(-4px); }
|
|
333
|
+
}
|
|
334
|
+
@keyframes fadeIn {
|
|
335
|
+
from { opacity: 0; transform: translateY(10px); }
|
|
336
|
+
to { opacity: 1; transform: translateY(0); }
|
|
337
|
+
}
|
|
338
|
+
.login-container { animation: fadeIn 0.3s ease-out; }
|
|
339
|
+
</style>
|
|
340
|
+
</head>
|
|
341
|
+
<body>
|
|
342
|
+
<div class="login-container">
|
|
343
|
+
<div class="logo">Kiro Mobile Bridge</div>
|
|
344
|
+
<div class="subtitle">Remote IDE Monitor</div>
|
|
345
|
+
<div class="otp-label">Enter Access Code</div>
|
|
346
|
+
<div class="otp-inputs" id="otpInputs">
|
|
347
|
+
<input type="tel" inputmode="numeric" pattern="[0-9]*" maxlength="1" autocomplete="one-time-code" aria-label="Digit 1">
|
|
348
|
+
<input type="tel" inputmode="numeric" pattern="[0-9]*" maxlength="1" autocomplete="off" aria-label="Digit 2">
|
|
349
|
+
<input type="tel" inputmode="numeric" pattern="[0-9]*" maxlength="1" autocomplete="off" aria-label="Digit 3">
|
|
350
|
+
<input type="tel" inputmode="numeric" pattern="[0-9]*" maxlength="1" autocomplete="off" aria-label="Digit 4">
|
|
351
|
+
<input type="tel" inputmode="numeric" pattern="[0-9]*" maxlength="1" autocomplete="off" aria-label="Digit 5">
|
|
352
|
+
<input type="tel" inputmode="numeric" pattern="[0-9]*" maxlength="1" autocomplete="off" aria-label="Digit 6">
|
|
353
|
+
</div>
|
|
354
|
+
<div id="errorMsg" class="error-message"></div>
|
|
355
|
+
<div class="hint">Check the terminal where you started the server for the 6-digit access code.</div>
|
|
356
|
+
</div>
|
|
357
|
+
<script>
|
|
358
|
+
const inputs = document.querySelectorAll('#otpInputs input');
|
|
359
|
+
const errorMsg = document.getElementById('errorMsg');
|
|
360
|
+
let submitting = false;
|
|
361
|
+
|
|
362
|
+
// Check lockout status on page load (covers new devices opening during lockout)
|
|
363
|
+
(async () => {
|
|
364
|
+
try {
|
|
365
|
+
const res = await fetch('/auth/status');
|
|
366
|
+
const data = await res.json();
|
|
367
|
+
if (data.consumed) {
|
|
368
|
+
// OTP already used by another device
|
|
369
|
+
showError('Access code already used. Restart the server for a new code.');
|
|
370
|
+
inputs.forEach(i => { i.disabled = true; });
|
|
371
|
+
} else if (data.locked && data.retryAfter > 0) {
|
|
372
|
+
startLockoutCountdown(data.retryAfter);
|
|
373
|
+
} else {
|
|
374
|
+
inputs[0].focus();
|
|
375
|
+
}
|
|
376
|
+
} catch {
|
|
377
|
+
inputs[0].focus();
|
|
378
|
+
}
|
|
379
|
+
})();
|
|
380
|
+
|
|
381
|
+
inputs.forEach((input, index) => {
|
|
382
|
+
input.addEventListener('input', (e) => {
|
|
383
|
+
const value = e.target.value.replace(/\\D/g, '');
|
|
384
|
+
e.target.value = value.slice(-1); // Keep only last digit
|
|
385
|
+
|
|
386
|
+
clearErrors();
|
|
387
|
+
|
|
388
|
+
if (value && index < inputs.length - 1) {
|
|
389
|
+
inputs[index + 1].focus();
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// Auto-submit when all 6 digits entered
|
|
393
|
+
if (getCode().length === 6) {
|
|
394
|
+
submitOTP();
|
|
395
|
+
}
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
input.addEventListener('keydown', (e) => {
|
|
399
|
+
if (e.key === 'Backspace') {
|
|
400
|
+
if (!e.target.value && index > 0) {
|
|
401
|
+
inputs[index - 1].focus();
|
|
402
|
+
inputs[index - 1].value = '';
|
|
403
|
+
}
|
|
404
|
+
} else if (e.key === 'ArrowLeft' && index > 0) {
|
|
405
|
+
inputs[index - 1].focus();
|
|
406
|
+
} else if (e.key === 'ArrowRight' && index < inputs.length - 1) {
|
|
407
|
+
inputs[index + 1].focus();
|
|
408
|
+
}
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
// Handle paste (e.g., pasting full 6-digit code)
|
|
412
|
+
input.addEventListener('paste', (e) => {
|
|
413
|
+
e.preventDefault();
|
|
414
|
+
const pasted = (e.clipboardData.getData('text') || '').replace(/\\D/g, '').slice(0, 6);
|
|
415
|
+
if (pasted.length > 0) {
|
|
416
|
+
for (let i = 0; i < inputs.length; i++) {
|
|
417
|
+
inputs[i].value = pasted[i] || '';
|
|
418
|
+
}
|
|
419
|
+
const focusIndex = Math.min(pasted.length, inputs.length - 1);
|
|
420
|
+
inputs[focusIndex].focus();
|
|
421
|
+
if (pasted.length === 6) submitOTP();
|
|
422
|
+
}
|
|
423
|
+
});
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
function getCode() {
|
|
427
|
+
return Array.from(inputs).map(i => i.value).join('');
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function clearErrors() {
|
|
431
|
+
errorMsg.textContent = '';
|
|
432
|
+
errorMsg.className = 'error-message';
|
|
433
|
+
inputs.forEach(i => i.classList.remove('error', 'success'));
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function showError(msg) {
|
|
437
|
+
errorMsg.textContent = msg;
|
|
438
|
+
errorMsg.className = 'error-message';
|
|
439
|
+
inputs.forEach(i => i.classList.add('error'));
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function showSuccess() {
|
|
443
|
+
errorMsg.textContent = 'Access granted!';
|
|
444
|
+
errorMsg.className = 'status-message';
|
|
445
|
+
inputs.forEach(i => {
|
|
446
|
+
i.classList.remove('error');
|
|
447
|
+
i.classList.add('success');
|
|
448
|
+
i.disabled = true;
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
let lockoutTimer = null;
|
|
453
|
+
|
|
454
|
+
function startLockoutCountdown(seconds) {
|
|
455
|
+
// Disable all inputs during lockout
|
|
456
|
+
inputs.forEach(i => { i.disabled = true; i.value = ''; });
|
|
457
|
+
let remaining = seconds;
|
|
458
|
+
showError('Too many attempts. Try again in ' + remaining + 's.');
|
|
459
|
+
|
|
460
|
+
if (lockoutTimer) clearInterval(lockoutTimer);
|
|
461
|
+
lockoutTimer = setInterval(() => {
|
|
462
|
+
remaining--;
|
|
463
|
+
if (remaining <= 0) {
|
|
464
|
+
clearInterval(lockoutTimer);
|
|
465
|
+
lockoutTimer = null;
|
|
466
|
+
clearErrors();
|
|
467
|
+
inputs.forEach(i => { i.disabled = false; });
|
|
468
|
+
inputs[0].focus();
|
|
469
|
+
} else {
|
|
470
|
+
showError('Too many attempts. Try again in ' + remaining + 's.');
|
|
471
|
+
}
|
|
472
|
+
}, 1000);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
async function submitOTP() {
|
|
476
|
+
if (submitting || lockoutTimer) return;
|
|
477
|
+
submitting = true;
|
|
478
|
+
|
|
479
|
+
const code = getCode();
|
|
480
|
+
try {
|
|
481
|
+
const res = await fetch('/auth/verify', {
|
|
482
|
+
method: 'POST',
|
|
483
|
+
headers: { 'Content-Type': 'application/json' },
|
|
484
|
+
body: JSON.stringify({ otp: code })
|
|
485
|
+
});
|
|
486
|
+
const data = await res.json();
|
|
487
|
+
|
|
488
|
+
if (data.success) {
|
|
489
|
+
showSuccess();
|
|
490
|
+
// Redirect to main app after brief success indication
|
|
491
|
+
setTimeout(() => { window.location.href = '/'; }, 600);
|
|
492
|
+
} else if (data.consumed) {
|
|
493
|
+
// OTP already used by another device — permanently lock
|
|
494
|
+
showError(data.error || 'Access code already used. Restart the server for a new code.');
|
|
495
|
+
inputs.forEach(i => { i.disabled = true; i.value = ''; });
|
|
496
|
+
if (data.retryAfter) startLockoutCountdown(data.retryAfter);
|
|
497
|
+
} else if (data.retryAfter) {
|
|
498
|
+
// Rate limited — start countdown
|
|
499
|
+
startLockoutCountdown(data.retryAfter);
|
|
500
|
+
} else {
|
|
501
|
+
showError(data.error || 'Invalid code.');
|
|
502
|
+
// Clear inputs on failure for retry
|
|
503
|
+
inputs.forEach(i => i.value = '');
|
|
504
|
+
inputs[0].focus();
|
|
505
|
+
}
|
|
506
|
+
} catch (err) {
|
|
507
|
+
showError('Connection error. Please try again.');
|
|
508
|
+
} finally {
|
|
509
|
+
submitting = false;
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
</script>
|
|
513
|
+
</body>
|
|
514
|
+
</html>`;
|
|
515
|
+
}
|
package/src/public/index.html
CHANGED
|
@@ -1000,6 +1000,25 @@
|
|
|
1000
1000
|
// State
|
|
1001
1001
|
// =============================================================================
|
|
1002
1002
|
let ws = null, reconnectAttempts = 0, reconnectTimeout = null;
|
|
1003
|
+
|
|
1004
|
+
// Handle 401 responses — redirect to login page
|
|
1005
|
+
function handleAuthError(response) {
|
|
1006
|
+
if (response && response.status === 401) {
|
|
1007
|
+
window.location.href = '/auth/login';
|
|
1008
|
+
return true;
|
|
1009
|
+
}
|
|
1010
|
+
return false;
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
// Wrap native fetch to auto-handle 401 auth errors
|
|
1014
|
+
const _originalFetch = window.fetch;
|
|
1015
|
+
window.fetch = async function (...args) {
|
|
1016
|
+
const response = await _originalFetch.apply(this, args);
|
|
1017
|
+
if (response.status === 401) {
|
|
1018
|
+
window.location.href = '/auth/login';
|
|
1019
|
+
}
|
|
1020
|
+
return response;
|
|
1021
|
+
};
|
|
1003
1022
|
let cascades = [], selectedCascadeId = null, currentStyles = null;
|
|
1004
1023
|
let isTypingInKiroInput = false, activePanel = 'chat';
|
|
1005
1024
|
let lastSuccessfulSnapshot = null, navigationPending = false;
|
|
@@ -1085,6 +1104,7 @@
|
|
|
1085
1104
|
// =============================================================================
|
|
1086
1105
|
function connect() {
|
|
1087
1106
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
1107
|
+
// Session cookie is automatically sent with the WebSocket upgrade request
|
|
1088
1108
|
ws = new WebSocket(`${protocol}//${window.location.host}`);
|
|
1089
1109
|
|
|
1090
1110
|
ws.onopen = () => { setStatus('connected'); reconnectAttempts = 0; };
|
package/src/server.js
CHANGED
|
@@ -27,6 +27,20 @@ import {
|
|
|
27
27
|
SNAPSHOT_IDLE_THRESHOLD
|
|
28
28
|
} from './utils/constants.js';
|
|
29
29
|
|
|
30
|
+
// Auth
|
|
31
|
+
import {
|
|
32
|
+
generateOTP,
|
|
33
|
+
getOTP,
|
|
34
|
+
setAuthEnabled,
|
|
35
|
+
isAuthEnabled,
|
|
36
|
+
authMiddleware,
|
|
37
|
+
validateWSAuth,
|
|
38
|
+
validateSession,
|
|
39
|
+
getLoginPageHTML,
|
|
40
|
+
verifyOTP,
|
|
41
|
+
getRateLimitStatus
|
|
42
|
+
} from './middleware/auth.js';
|
|
43
|
+
|
|
30
44
|
// Routes
|
|
31
45
|
import { createApiRouter } from './routes/api.js';
|
|
32
46
|
|
|
@@ -38,6 +52,13 @@ const __dirname = dirname(__filename);
|
|
|
38
52
|
// =============================================================================
|
|
39
53
|
|
|
40
54
|
const PORT = process.env.PORT || 3000;
|
|
55
|
+
const NO_AUTH = process.argv.includes('--no-auth');
|
|
56
|
+
|
|
57
|
+
// Configure authentication
|
|
58
|
+
setAuthEnabled(!NO_AUTH);
|
|
59
|
+
if (!NO_AUTH) {
|
|
60
|
+
generateOTP();
|
|
61
|
+
}
|
|
41
62
|
|
|
42
63
|
// =============================================================================
|
|
43
64
|
// State Management
|
|
@@ -66,24 +87,24 @@ async function discoverTargets() {
|
|
|
66
87
|
const foundCascadeIds = new Set();
|
|
67
88
|
let foundMainWindow = false;
|
|
68
89
|
let stateChanged = false;
|
|
69
|
-
|
|
90
|
+
|
|
70
91
|
const portResults = await Promise.allSettled(
|
|
71
92
|
CDP_PORTS.map(port => fetchCDPTargets(port).then(targets => ({ port, targets })))
|
|
72
93
|
);
|
|
73
|
-
|
|
94
|
+
|
|
74
95
|
for (const result of portResults) {
|
|
75
96
|
if (result.status !== 'fulfilled') continue;
|
|
76
97
|
const { port, targets } = result.value;
|
|
77
|
-
|
|
98
|
+
|
|
78
99
|
try {
|
|
79
100
|
// Find main VS Code window
|
|
80
101
|
const mainWindowTarget = targets.find(target => {
|
|
81
102
|
const url = (target.url || '').toLowerCase();
|
|
82
|
-
return target.type === 'page' &&
|
|
83
|
-
|
|
84
|
-
|
|
103
|
+
return target.type === 'page' &&
|
|
104
|
+
(url.startsWith('vscode-file://') || url.includes('workbench')) &&
|
|
105
|
+
target.webSocketDebuggerUrl;
|
|
85
106
|
});
|
|
86
|
-
|
|
107
|
+
|
|
87
108
|
if (mainWindowTarget && !mainWindowCDP.connection) {
|
|
88
109
|
console.log(`[Discovery] Found main VS Code window: ${mainWindowTarget.title}`);
|
|
89
110
|
try {
|
|
@@ -92,7 +113,7 @@ async function discoverTargets() {
|
|
|
92
113
|
mainWindowCDP.id = generateId(mainWindowTarget.webSocketDebuggerUrl);
|
|
93
114
|
foundMainWindow = true;
|
|
94
115
|
stateChanged = true;
|
|
95
|
-
|
|
116
|
+
|
|
96
117
|
cdp.ws.on('close', () => {
|
|
97
118
|
console.log(`[Discovery] Main window disconnected`);
|
|
98
119
|
mainWindowCDP.connection = null;
|
|
@@ -105,25 +126,25 @@ async function discoverTargets() {
|
|
|
105
126
|
} else if (mainWindowTarget) {
|
|
106
127
|
foundMainWindow = true;
|
|
107
128
|
}
|
|
108
|
-
|
|
129
|
+
|
|
109
130
|
// Find Kiro Agent webviews
|
|
110
131
|
const kiroAgentTargets = targets.filter(target => {
|
|
111
132
|
const url = (target.url || '').toLowerCase();
|
|
112
|
-
return (url.includes('kiroagent') || url.includes('vscode-webview')) &&
|
|
113
|
-
|
|
133
|
+
return (url.includes('kiroagent') || url.includes('vscode-webview')) &&
|
|
134
|
+
target.webSocketDebuggerUrl && target.type !== 'page';
|
|
114
135
|
});
|
|
115
|
-
|
|
136
|
+
|
|
116
137
|
for (const target of kiroAgentTargets) {
|
|
117
138
|
const wsUrl = target.webSocketDebuggerUrl;
|
|
118
139
|
const cascadeId = generateId(wsUrl);
|
|
119
140
|
foundCascadeIds.add(cascadeId);
|
|
120
|
-
|
|
141
|
+
|
|
121
142
|
if (!cascades.has(cascadeId)) {
|
|
122
143
|
stateChanged = true;
|
|
123
|
-
|
|
144
|
+
|
|
124
145
|
try {
|
|
125
146
|
const cdp = await connectToCDP(wsUrl);
|
|
126
|
-
|
|
147
|
+
|
|
127
148
|
cascades.set(cascadeId, {
|
|
128
149
|
id: cascadeId,
|
|
129
150
|
cdp,
|
|
@@ -134,14 +155,14 @@ async function discoverTargets() {
|
|
|
134
155
|
editor: null,
|
|
135
156
|
editorHash: null
|
|
136
157
|
});
|
|
137
|
-
|
|
158
|
+
|
|
138
159
|
cdp.ws.on('close', () => {
|
|
139
160
|
console.log(`[Discovery] Cascade disconnected: ${cascadeId}`);
|
|
140
161
|
cascades.delete(cascadeId);
|
|
141
162
|
broadcastCascadeList();
|
|
142
163
|
adjustDiscoveryInterval(true);
|
|
143
164
|
});
|
|
144
|
-
|
|
165
|
+
|
|
145
166
|
broadcastCascadeList();
|
|
146
167
|
} catch (err) {
|
|
147
168
|
console.error(`[Discovery] Failed to connect to ${cascadeId}: ${err.message}`);
|
|
@@ -155,14 +176,14 @@ async function discoverTargets() {
|
|
|
155
176
|
console.debug(`[Discovery] Error scanning port ${port}: ${err.message}`);
|
|
156
177
|
}
|
|
157
178
|
}
|
|
158
|
-
|
|
179
|
+
|
|
159
180
|
// Clean up disconnected targets
|
|
160
181
|
for (const [cascadeId, cascade] of cascades) {
|
|
161
182
|
if (!foundCascadeIds.has(cascadeId)) {
|
|
162
183
|
console.log(`[Discovery] Target no longer available: ${cascadeId}`);
|
|
163
184
|
stateChanged = true;
|
|
164
|
-
try {
|
|
165
|
-
cascade.cdp.close();
|
|
185
|
+
try {
|
|
186
|
+
cascade.cdp.close();
|
|
166
187
|
} catch (e) {
|
|
167
188
|
console.debug(`[Discovery] Error closing cascade ${cascadeId}: ${e.message}`);
|
|
168
189
|
}
|
|
@@ -170,10 +191,10 @@ async function discoverTargets() {
|
|
|
170
191
|
broadcastCascadeList();
|
|
171
192
|
}
|
|
172
193
|
}
|
|
173
|
-
|
|
194
|
+
|
|
174
195
|
const mainWindowChanged = foundMainWindow !== pollingState.lastMainWindowConnected;
|
|
175
196
|
const cascadeCountChanged = cascades.size !== pollingState.lastCascadeCount;
|
|
176
|
-
|
|
197
|
+
|
|
177
198
|
if (stateChanged || mainWindowChanged || cascadeCountChanged) {
|
|
178
199
|
console.log(`[Discovery] Active cascades: ${cascades.size}${foundMainWindow ? ' (main window connected)' : ''}`);
|
|
179
200
|
pollingState.lastCascadeCount = cascades.size;
|
|
@@ -190,21 +211,21 @@ async function discoverTargets() {
|
|
|
190
211
|
|
|
191
212
|
async function pollSnapshots() {
|
|
192
213
|
let anyChanges = false;
|
|
193
|
-
|
|
214
|
+
|
|
194
215
|
for (const [cascadeId, cascade] of cascades) {
|
|
195
216
|
try {
|
|
196
217
|
const cdp = cascade.cdp;
|
|
197
|
-
|
|
218
|
+
|
|
198
219
|
// Capture CSS once
|
|
199
220
|
if (cascade.css === null) {
|
|
200
221
|
cascade.css = await captureCSS(cdp);
|
|
201
222
|
}
|
|
202
|
-
|
|
223
|
+
|
|
203
224
|
// Capture metadata
|
|
204
225
|
const metadata = await captureMetadata(cdp);
|
|
205
226
|
cascade.metadata.chatTitle = metadata.chatTitle || cascade.metadata.chatTitle;
|
|
206
227
|
cascade.metadata.isActive = metadata.isActive;
|
|
207
|
-
|
|
228
|
+
|
|
208
229
|
// Capture chat snapshot
|
|
209
230
|
const snapshot = await captureSnapshot(cdp);
|
|
210
231
|
if (snapshot) {
|
|
@@ -216,7 +237,7 @@ async function pollSnapshots() {
|
|
|
216
237
|
anyChanges = true;
|
|
217
238
|
}
|
|
218
239
|
}
|
|
219
|
-
|
|
240
|
+
|
|
220
241
|
// Capture editor from main window
|
|
221
242
|
// Store rootContextId locally to avoid race conditions during async operations
|
|
222
243
|
const mainCDP = mainWindowCDP.connection;
|
|
@@ -242,7 +263,7 @@ async function pollSnapshots() {
|
|
|
242
263
|
console.error(`[Snapshot] Error polling cascade ${cascadeId}:`, err.message);
|
|
243
264
|
}
|
|
244
265
|
}
|
|
245
|
-
|
|
266
|
+
|
|
246
267
|
adjustSnapshotInterval(anyChanges);
|
|
247
268
|
}
|
|
248
269
|
|
|
@@ -316,7 +337,7 @@ function broadcastCascadeList() {
|
|
|
316
337
|
window: c.metadata?.windowTitle || 'Unknown',
|
|
317
338
|
active: c.metadata?.isActive || false
|
|
318
339
|
}));
|
|
319
|
-
|
|
340
|
+
|
|
320
341
|
const message = JSON.stringify({ type: 'cascade_list', cascades: cascadeList });
|
|
321
342
|
for (const client of wss.clients) {
|
|
322
343
|
if (client.readyState === WebSocket.OPEN) client.send(message);
|
|
@@ -338,6 +359,51 @@ app.use((req, res, next) => {
|
|
|
338
359
|
next();
|
|
339
360
|
});
|
|
340
361
|
|
|
362
|
+
// Auth routes — must be before authMiddleware
|
|
363
|
+
app.get('/auth/login', (req, res) => {
|
|
364
|
+
res.type('html').send(getLoginPageHTML());
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
app.post('/auth/verify', (req, res) => {
|
|
368
|
+
const { otp } = req.body;
|
|
369
|
+
const result = verifyOTP(otp);
|
|
370
|
+
|
|
371
|
+
if (result.success) {
|
|
372
|
+
// Set HttpOnly session cookie (no Secure flag — this is HTTP-only LAN tool)
|
|
373
|
+
res.cookie('kmb_session', result.token, {
|
|
374
|
+
httpOnly: true,
|
|
375
|
+
sameSite: 'strict',
|
|
376
|
+
path: '/'
|
|
377
|
+
});
|
|
378
|
+
console.log(`[Auth] Device authenticated successfully`);
|
|
379
|
+
res.json({ success: true });
|
|
380
|
+
} else {
|
|
381
|
+
console.log(`[Auth] Failed attempt: ${result.error}`);
|
|
382
|
+
res.status(401).json({
|
|
383
|
+
success: false,
|
|
384
|
+
error: result.error,
|
|
385
|
+
retryAfter: result.retryAfter || null
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
app.get('/auth/status', (req, res) => {
|
|
391
|
+
const cookie = req.headers.cookie || '';
|
|
392
|
+
const match = cookie.match(/(?:^|;\s*)kmb_session=([a-f0-9]{64})(?:;|$)/);
|
|
393
|
+
const token = match ? match[1] : null;
|
|
394
|
+
const rateLimit = getRateLimitStatus();
|
|
395
|
+
res.json({
|
|
396
|
+
authenticated: token ? validateSession(token) : false,
|
|
397
|
+
authEnabled: isAuthEnabled(),
|
|
398
|
+
locked: rateLimit.locked,
|
|
399
|
+
consumed: rateLimit.consumed,
|
|
400
|
+
retryAfter: rateLimit.retryAfter
|
|
401
|
+
});
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
// Authentication gate — all routes below require valid session
|
|
405
|
+
app.use(authMiddleware);
|
|
406
|
+
|
|
341
407
|
app.use(express.static(join(__dirname, 'public')));
|
|
342
408
|
|
|
343
409
|
// Mount API routes
|
|
@@ -353,8 +419,16 @@ wss = new WebSocketServer({ server: httpServer });
|
|
|
353
419
|
|
|
354
420
|
wss.on('connection', (ws, req) => {
|
|
355
421
|
const clientIP = req.socket.remoteAddress || 'unknown';
|
|
422
|
+
|
|
423
|
+
// Validate WebSocket authentication
|
|
424
|
+
if (!validateWSAuth(req)) {
|
|
425
|
+
console.log(`[WebSocket] Unauthorized connection from ${clientIP}`);
|
|
426
|
+
ws.close(4401, 'Unauthorized');
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
|
|
356
430
|
console.log(`[WebSocket] Client connected from ${clientIP}`);
|
|
357
|
-
|
|
431
|
+
|
|
358
432
|
// Send cascade list on connect
|
|
359
433
|
const cascadeList = Array.from(cascades.values()).map(c => ({
|
|
360
434
|
id: c.id,
|
|
@@ -362,9 +436,9 @@ wss.on('connection', (ws, req) => {
|
|
|
362
436
|
window: c.metadata?.windowTitle || 'Unknown',
|
|
363
437
|
active: c.metadata?.isActive || false
|
|
364
438
|
}));
|
|
365
|
-
|
|
439
|
+
|
|
366
440
|
ws.send(JSON.stringify({ type: 'cascade_list', cascades: cascadeList }));
|
|
367
|
-
|
|
441
|
+
|
|
368
442
|
ws.on('close', () => console.log(`[WebSocket] Client disconnected from ${clientIP}`));
|
|
369
443
|
ws.on('error', (err) => console.error(`[WebSocket] Error from ${clientIP}:`, err.message));
|
|
370
444
|
});
|
|
@@ -377,9 +451,15 @@ httpServer.listen(PORT, '0.0.0.0', () => {
|
|
|
377
451
|
console.log(`Local: http://localhost:${PORT}`);
|
|
378
452
|
console.log(`Network: http://${localIP}:${PORT}`);
|
|
379
453
|
console.log('');
|
|
380
|
-
|
|
454
|
+
if (isAuthEnabled()) {
|
|
455
|
+
console.log(`\x1b[33m\x1b[1m🔑 Access Code: ${getOTP()}\x1b[0m`);
|
|
456
|
+
console.log('');
|
|
457
|
+
console.log('Enter this code on your device to connect.');
|
|
458
|
+
} else {
|
|
459
|
+
console.log('Auth disabled (--no-auth). Open the Network URL on your phone.');
|
|
460
|
+
}
|
|
381
461
|
console.log('');
|
|
382
|
-
|
|
462
|
+
|
|
383
463
|
// Start discovery and polling
|
|
384
464
|
discoverTargets();
|
|
385
465
|
pollingState.discoveryInterval = setInterval(discoverTargets, pollingState.discoveryIntervalMs);
|
package/src/utils/constants.js
CHANGED
|
@@ -103,6 +103,12 @@ export const SNAPSHOT_INTERVAL_ACTIVE = 200; // 200ms when active (very fast
|
|
|
103
103
|
export const SNAPSHOT_INTERVAL_IDLE = 800; // 800ms when idle
|
|
104
104
|
export const SNAPSHOT_IDLE_THRESHOLD = 3000; // 3 seconds before considered idle
|
|
105
105
|
|
|
106
|
+
/**
|
|
107
|
+
* OTP authentication settings
|
|
108
|
+
*/
|
|
109
|
+
export const OTP_MAX_ATTEMPTS = 5; // Max failed attempts before lockout
|
|
110
|
+
export const OTP_LOCKOUT_MS = 60000; // 60 second lockout after max attempts
|
|
111
|
+
|
|
106
112
|
/**
|
|
107
113
|
* Maximum depth for recursive file search
|
|
108
114
|
* @type {number}
|