create-fluxstack 1.13.0 → 1.14.0
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/LLMD/patterns/anti-patterns.md +100 -0
- package/LLMD/reference/routing.md +39 -39
- package/LLMD/resources/live-auth.md +20 -2
- package/LLMD/resources/live-components.md +94 -10
- package/LLMD/resources/live-logging.md +95 -33
- package/LLMD/resources/live-upload.md +59 -8
- package/app/client/index.html +2 -2
- package/app/client/public/favicon.svg +46 -0
- package/app/client/src/App.tsx +2 -1
- package/app/client/src/assets/fluxstack-static.svg +46 -0
- package/app/client/src/assets/fluxstack.svg +183 -0
- package/app/client/src/components/AppLayout.tsx +138 -9
- package/app/client/src/components/BackButton.tsx +13 -13
- package/app/client/src/components/DemoPage.tsx +4 -4
- package/app/client/src/live/AuthDemo.tsx +23 -21
- package/app/client/src/live/ChatDemo.tsx +2 -2
- package/app/client/src/live/CounterDemo.tsx +12 -12
- package/app/client/src/live/FormDemo.tsx +2 -2
- package/app/client/src/live/LiveDebuggerPanel.tsx +779 -0
- package/app/client/src/live/RoomChatDemo.tsx +24 -16
- package/app/client/src/main.tsx +13 -13
- package/app/client/src/pages/ApiTestPage.tsx +6 -6
- package/app/client/src/pages/HomePage.tsx +80 -52
- package/app/server/live/LiveAdminPanel.ts +1 -0
- package/app/server/live/LiveChat.ts +78 -77
- package/app/server/live/LiveCounter.ts +1 -1
- package/app/server/live/LiveForm.ts +1 -0
- package/app/server/live/LiveLocalCounter.ts +38 -37
- package/app/server/live/LiveProtectedChat.ts +1 -0
- package/app/server/live/LiveRoomChat.ts +1 -0
- package/app/server/live/LiveUpload.ts +1 -0
- package/app/server/live/register-components.ts +19 -19
- package/config/system/runtime.config.ts +4 -0
- package/core/build/optimizer.ts +235 -235
- package/core/client/components/Live.tsx +17 -11
- package/core/client/components/LiveDebugger.tsx +1324 -0
- package/core/client/hooks/AdaptiveChunkSizer.ts +215 -215
- package/core/client/hooks/useLiveComponent.ts +11 -1
- package/core/client/hooks/useLiveDebugger.ts +392 -0
- package/core/client/index.ts +14 -0
- package/core/plugins/built-in/index.ts +134 -134
- package/core/plugins/built-in/live-components/commands/create-live-component.ts +4 -0
- package/core/plugins/built-in/vite/index.ts +75 -21
- package/core/server/index.ts +15 -15
- package/core/server/live/ComponentRegistry.ts +55 -26
- package/core/server/live/FileUploadManager.ts +188 -24
- package/core/server/live/LiveDebugger.ts +462 -0
- package/core/server/live/LiveLogger.ts +38 -5
- package/core/server/live/LiveRoomManager.ts +17 -1
- package/core/server/live/StateSignature.ts +87 -27
- package/core/server/live/WebSocketConnectionManager.ts +11 -10
- package/core/server/live/auto-generated-components.ts +1 -1
- package/core/server/live/websocket-plugin.ts +233 -8
- package/core/server/plugins/static-files-plugin.ts +179 -69
- package/core/types/build.ts +219 -219
- package/core/types/plugin.ts +107 -107
- package/core/types/types.ts +145 -9
- package/core/utils/logger/startup-banner.ts +82 -82
- package/core/utils/version.ts +6 -6
- package/package.json +1 -1
- package/app/client/src/assets/react.svg +0 -1
|
@@ -18,6 +18,7 @@ export interface SignedState<T = any> {
|
|
|
18
18
|
keyId?: string // For key rotation
|
|
19
19
|
compressed?: boolean // For state compression
|
|
20
20
|
encrypted?: boolean // For sensitive data
|
|
21
|
+
nonce?: string // 🔒 Anti-replay: unique per signed state
|
|
21
22
|
}
|
|
22
23
|
|
|
23
24
|
export interface StateValidationResult {
|
|
@@ -26,6 +27,7 @@ export interface StateValidationResult {
|
|
|
26
27
|
tampered?: boolean
|
|
27
28
|
expired?: boolean
|
|
28
29
|
keyRotated?: boolean
|
|
30
|
+
replayed?: boolean // 🔒 Anti-replay: nonce was already consumed
|
|
29
31
|
}
|
|
30
32
|
|
|
31
33
|
export interface StateBackup<T = any> {
|
|
@@ -57,6 +59,10 @@ export class StateSignature {
|
|
|
57
59
|
private compressionConfig: CompressionConfig
|
|
58
60
|
private backups = new Map<string, StateBackup[]>() // componentId -> backups
|
|
59
61
|
private migrationFunctions = new Map<string, (state: any) => any>() // version -> migration function
|
|
62
|
+
// 🔒 Anti-replay: track consumed nonces to prevent state replay attacks
|
|
63
|
+
private consumedNonces = new Set<string>()
|
|
64
|
+
private readonly nonceMaxAge = 24 * 60 * 60 * 1000 // Nonces expire with the state (24h)
|
|
65
|
+
private nonceTimestamps = new Map<string, number>() // nonce -> timestamp for cleanup
|
|
60
66
|
|
|
61
67
|
constructor(secretKey?: string, options?: {
|
|
62
68
|
keyRotation?: Partial<KeyRotationConfig>
|
|
@@ -108,10 +114,11 @@ export class StateSignature {
|
|
|
108
114
|
setInterval(() => {
|
|
109
115
|
this.rotateKey()
|
|
110
116
|
}, this.keyRotationConfig.rotationInterval)
|
|
111
|
-
|
|
112
|
-
// Cleanup old keys
|
|
117
|
+
|
|
118
|
+
// Cleanup old keys and expired nonces
|
|
113
119
|
setInterval(() => {
|
|
114
120
|
this.cleanupOldKeys()
|
|
121
|
+
this.cleanupExpiredNonces()
|
|
115
122
|
}, 24 * 60 * 60 * 1000) // Daily cleanup
|
|
116
123
|
}
|
|
117
124
|
|
|
@@ -159,6 +166,26 @@ export class StateSignature {
|
|
|
159
166
|
}
|
|
160
167
|
}
|
|
161
168
|
|
|
169
|
+
/**
|
|
170
|
+
* 🔒 Remove expired nonces to prevent unbounded memory growth
|
|
171
|
+
*/
|
|
172
|
+
private cleanupExpiredNonces(): void {
|
|
173
|
+
const now = Date.now()
|
|
174
|
+
let cleaned = 0
|
|
175
|
+
|
|
176
|
+
for (const [nonce, timestamp] of this.nonceTimestamps) {
|
|
177
|
+
if (now - timestamp > this.nonceMaxAge) {
|
|
178
|
+
this.consumedNonces.delete(nonce)
|
|
179
|
+
this.nonceTimestamps.delete(nonce)
|
|
180
|
+
cleaned++
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (cleaned > 0) {
|
|
185
|
+
liveLog('state', null, `🧹 Cleaned up ${cleaned} expired nonces (${this.consumedNonces.size} active)`)
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
162
189
|
private getKeyById(keyId: string): string | null {
|
|
163
190
|
const keyData = this.keyHistory.get(keyId)
|
|
164
191
|
return keyData ? keyData.key : null
|
|
@@ -179,37 +206,38 @@ export class StateSignature {
|
|
|
179
206
|
): Promise<SignedState<T>> {
|
|
180
207
|
const timestamp = Date.now()
|
|
181
208
|
const keyId = this.getCurrentKeyId()
|
|
182
|
-
|
|
209
|
+
const nonce = randomBytes(16).toString('hex') // 🔒 Anti-replay nonce
|
|
210
|
+
|
|
183
211
|
let processedData = data
|
|
184
212
|
let compressed = false
|
|
185
213
|
let encrypted = false
|
|
186
|
-
|
|
214
|
+
|
|
187
215
|
try {
|
|
188
216
|
// Serialize data for processing
|
|
189
217
|
const serializedData = JSON.stringify(data)
|
|
190
|
-
|
|
218
|
+
|
|
191
219
|
// Compress if enabled and data is large enough
|
|
192
|
-
if (this.compressionConfig.enabled &&
|
|
220
|
+
if (this.compressionConfig.enabled &&
|
|
193
221
|
(options?.compress !== false) &&
|
|
194
222
|
Buffer.byteLength(serializedData, 'utf8') > this.compressionConfig.threshold) {
|
|
195
|
-
|
|
223
|
+
|
|
196
224
|
const compressedBuffer = await gzipAsync(Buffer.from(serializedData, 'utf8'))
|
|
197
225
|
processedData = compressedBuffer.toString('base64') as any
|
|
198
226
|
compressed = true
|
|
199
|
-
|
|
227
|
+
|
|
200
228
|
liveLog('state', componentId, `🗜️ State compressed: ${Buffer.byteLength(serializedData, 'utf8')} -> ${compressedBuffer.length} bytes`)
|
|
201
229
|
}
|
|
202
|
-
|
|
230
|
+
|
|
203
231
|
// Encrypt sensitive data if requested
|
|
204
232
|
if (options?.encrypt) {
|
|
205
233
|
const encryptedData = await this.encryptData(processedData)
|
|
206
234
|
processedData = encryptedData as any
|
|
207
235
|
encrypted = true
|
|
208
|
-
|
|
236
|
+
|
|
209
237
|
liveLog('state', componentId, `🔒 State encrypted for component: ${componentId}`)
|
|
210
238
|
}
|
|
211
|
-
|
|
212
|
-
// Create payload for signing
|
|
239
|
+
|
|
240
|
+
// Create payload for signing (includes nonce for anti-replay)
|
|
213
241
|
const payload = {
|
|
214
242
|
data: processedData,
|
|
215
243
|
componentId,
|
|
@@ -217,9 +245,10 @@ export class StateSignature {
|
|
|
217
245
|
version,
|
|
218
246
|
keyId,
|
|
219
247
|
compressed,
|
|
220
|
-
encrypted
|
|
248
|
+
encrypted,
|
|
249
|
+
nonce
|
|
221
250
|
}
|
|
222
|
-
|
|
251
|
+
|
|
223
252
|
// Generate signature with current key
|
|
224
253
|
const signature = this.createSignature(payload)
|
|
225
254
|
|
|
@@ -235,6 +264,7 @@ export class StateSignature {
|
|
|
235
264
|
keyId,
|
|
236
265
|
compressed,
|
|
237
266
|
encrypted,
|
|
267
|
+
nonce: nonce.substring(0, 8) + '...',
|
|
238
268
|
signature: signature.substring(0, 16) + '...'
|
|
239
269
|
})
|
|
240
270
|
|
|
@@ -246,7 +276,8 @@ export class StateSignature {
|
|
|
246
276
|
version,
|
|
247
277
|
keyId,
|
|
248
278
|
compressed,
|
|
249
|
-
encrypted
|
|
279
|
+
encrypted,
|
|
280
|
+
nonce
|
|
250
281
|
}
|
|
251
282
|
|
|
252
283
|
} catch (error) {
|
|
@@ -258,14 +289,19 @@ export class StateSignature {
|
|
|
258
289
|
/**
|
|
259
290
|
* Validate signed state integrity with enhanced security checks
|
|
260
291
|
*/
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
292
|
+
/**
|
|
293
|
+
* Validate signed state integrity with enhanced security checks.
|
|
294
|
+
* @param consumeNonce If true (default), the nonce is consumed and the same signed state cannot be reused.
|
|
295
|
+
* Set to false for read-only validation without consuming the nonce.
|
|
296
|
+
*/
|
|
297
|
+
public async validateState<T>(signedState: SignedState<T>, maxAge?: number, consumeNonce = true): Promise<StateValidationResult> {
|
|
298
|
+
const { data, signature, timestamp, componentId, version, keyId, compressed, encrypted, nonce } = signedState
|
|
299
|
+
|
|
264
300
|
try {
|
|
265
301
|
// Check timestamp (prevent replay attacks)
|
|
266
302
|
const age = Date.now() - timestamp
|
|
267
303
|
const ageLimit = maxAge || this.maxAge
|
|
268
|
-
|
|
304
|
+
|
|
269
305
|
if (age > ageLimit) {
|
|
270
306
|
return {
|
|
271
307
|
valid: false,
|
|
@@ -274,10 +310,23 @@ export class StateSignature {
|
|
|
274
310
|
}
|
|
275
311
|
}
|
|
276
312
|
|
|
313
|
+
// 🔒 Anti-replay: check if this nonce was already consumed
|
|
314
|
+
if (nonce && consumeNonce && this.consumedNonces.has(nonce)) {
|
|
315
|
+
liveWarn('state', componentId, '⚠️ Replay attack detected - nonce already consumed:', {
|
|
316
|
+
componentId,
|
|
317
|
+
nonce: nonce.substring(0, 8) + '...'
|
|
318
|
+
})
|
|
319
|
+
return {
|
|
320
|
+
valid: false,
|
|
321
|
+
error: 'State already consumed - replay attack detected',
|
|
322
|
+
replayed: true
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
277
326
|
// Determine which key to use for validation
|
|
278
327
|
let validationKey = this.currentKey
|
|
279
328
|
let keyRotated = false
|
|
280
|
-
|
|
329
|
+
|
|
281
330
|
if (keyId) {
|
|
282
331
|
const historicalKey = this.getKeyById(keyId)
|
|
283
332
|
if (historicalKey) {
|
|
@@ -292,27 +341,30 @@ export class StateSignature {
|
|
|
292
341
|
}
|
|
293
342
|
}
|
|
294
343
|
|
|
295
|
-
// Recreate payload for verification
|
|
296
|
-
const payload = {
|
|
344
|
+
// Recreate payload for verification (must include nonce if present)
|
|
345
|
+
const payload: Record<string, unknown> = {
|
|
297
346
|
data,
|
|
298
347
|
componentId,
|
|
299
348
|
timestamp,
|
|
300
349
|
version,
|
|
301
350
|
keyId,
|
|
302
351
|
compressed,
|
|
303
|
-
encrypted
|
|
352
|
+
encrypted,
|
|
353
|
+
}
|
|
354
|
+
if (nonce !== undefined) {
|
|
355
|
+
payload.nonce = nonce
|
|
304
356
|
}
|
|
305
357
|
|
|
306
358
|
// Verify signature with appropriate key
|
|
307
359
|
const expectedSignature = this.createSignature(payload, validationKey)
|
|
308
|
-
|
|
360
|
+
|
|
309
361
|
if (!this.constantTimeEquals(signature, expectedSignature)) {
|
|
310
362
|
liveWarn('state', componentId, '⚠️ State signature mismatch:', {
|
|
311
363
|
componentId,
|
|
312
364
|
expected: expectedSignature.substring(0, 16) + '...',
|
|
313
365
|
received: signature.substring(0, 16) + '...'
|
|
314
366
|
})
|
|
315
|
-
|
|
367
|
+
|
|
316
368
|
return {
|
|
317
369
|
valid: false,
|
|
318
370
|
error: 'State signature invalid - possible tampering',
|
|
@@ -320,10 +372,17 @@ export class StateSignature {
|
|
|
320
372
|
}
|
|
321
373
|
}
|
|
322
374
|
|
|
375
|
+
// 🔒 Anti-replay: consume the nonce so it cannot be reused
|
|
376
|
+
if (nonce && consumeNonce) {
|
|
377
|
+
this.consumedNonces.add(nonce)
|
|
378
|
+
this.nonceTimestamps.set(nonce, Date.now())
|
|
379
|
+
}
|
|
380
|
+
|
|
323
381
|
liveLog('state', componentId, '✅ State signature valid:', {
|
|
324
382
|
componentId,
|
|
325
383
|
age: `${Math.round(age / 1000)}s`,
|
|
326
|
-
version
|
|
384
|
+
version,
|
|
385
|
+
nonceConsumed: !!(nonce && consumeNonce)
|
|
327
386
|
})
|
|
328
387
|
|
|
329
388
|
return { valid: true }
|
|
@@ -622,7 +681,8 @@ export class StateSignature {
|
|
|
622
681
|
currentKeyId: this.getCurrentKeyId(),
|
|
623
682
|
keyHistoryCount: this.keyHistory.size,
|
|
624
683
|
compressionEnabled: this.compressionConfig.enabled,
|
|
625
|
-
rotationInterval: this.keyRotationConfig.rotationInterval
|
|
684
|
+
rotationInterval: this.keyRotationConfig.rotationInterval,
|
|
685
|
+
activeNonces: this.consumedNonces.size // 🔒 Anti-replay tracking
|
|
626
686
|
}
|
|
627
687
|
}
|
|
628
688
|
}
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
import { EventEmitter } from 'events'
|
|
5
5
|
import type { FluxStackWebSocket } from '@core/types/types'
|
|
6
|
+
import { liveLog, liveWarn } from './LiveLogger'
|
|
6
7
|
|
|
7
8
|
export interface ConnectionConfig {
|
|
8
9
|
maxConnections: number
|
|
@@ -123,7 +124,7 @@ export class WebSocketConnectionManager extends EventEmitter {
|
|
|
123
124
|
// Setup connection event handlers
|
|
124
125
|
this.setupConnectionHandlers(ws, connectionId)
|
|
125
126
|
|
|
126
|
-
|
|
127
|
+
liveLog('websocket', null, `🔌 Connection registered: ${connectionId} (Pool: ${poolId || 'default'})`)
|
|
127
128
|
this.emit('connectionRegistered', { connectionId, poolId })
|
|
128
129
|
}
|
|
129
130
|
|
|
@@ -193,7 +194,7 @@ export class WebSocketConnectionManager extends EventEmitter {
|
|
|
193
194
|
}
|
|
194
195
|
|
|
195
196
|
this.connectionPools.get(poolId)!.add(connectionId)
|
|
196
|
-
|
|
197
|
+
liveLog('websocket', null, `🏊 Connection ${connectionId} added to pool ${poolId}`)
|
|
197
198
|
}
|
|
198
199
|
|
|
199
200
|
/**
|
|
@@ -331,7 +332,7 @@ export class WebSocketConnectionManager extends EventEmitter {
|
|
|
331
332
|
queue.splice(insertIndex, 0, queuedMessage)
|
|
332
333
|
}
|
|
333
334
|
|
|
334
|
-
|
|
335
|
+
liveLog('messages', null, `📬 Message queued for ${connectionId}: ${queuedMessage.id}`)
|
|
335
336
|
return true
|
|
336
337
|
}
|
|
337
338
|
|
|
@@ -361,10 +362,10 @@ export class WebSocketConnectionManager extends EventEmitter {
|
|
|
361
362
|
// Re-queue for retry
|
|
362
363
|
queue.push(queuedMessage)
|
|
363
364
|
} else {
|
|
364
|
-
|
|
365
|
+
liveWarn('messages', null, `❌ Message ${queuedMessage.id} exceeded max retries`)
|
|
365
366
|
}
|
|
366
367
|
} else {
|
|
367
|
-
|
|
368
|
+
liveLog('messages', null, `✅ Queued message delivered: ${queuedMessage.id}`)
|
|
368
369
|
}
|
|
369
370
|
} catch (error) {
|
|
370
371
|
console.error(`❌ Error processing queued message ${queuedMessage.id}:`, error)
|
|
@@ -445,7 +446,7 @@ export class WebSocketConnectionManager extends EventEmitter {
|
|
|
445
446
|
* Handle connection close
|
|
446
447
|
*/
|
|
447
448
|
private handleConnectionClose(connectionId: string): void {
|
|
448
|
-
|
|
449
|
+
liveLog('websocket', null, `🔌 Connection closed: ${connectionId}`)
|
|
449
450
|
|
|
450
451
|
// Update metrics
|
|
451
452
|
const metrics = this.connectionMetrics.get(connectionId)
|
|
@@ -573,7 +574,7 @@ export class WebSocketConnectionManager extends EventEmitter {
|
|
|
573
574
|
* Handle unhealthy connection
|
|
574
575
|
*/
|
|
575
576
|
private async handleUnhealthyConnection(connectionId: string): Promise<void> {
|
|
576
|
-
|
|
577
|
+
liveWarn('websocket', null, `⚠️ Handling unhealthy connection: ${connectionId}`)
|
|
577
578
|
|
|
578
579
|
const ws = this.connections.get(connectionId)
|
|
579
580
|
if (ws) {
|
|
@@ -606,7 +607,7 @@ export class WebSocketConnectionManager extends EventEmitter {
|
|
|
606
607
|
}
|
|
607
608
|
}
|
|
608
609
|
|
|
609
|
-
|
|
610
|
+
liveLog('websocket', null, `🧹 Connection cleaned up: ${connectionId}`)
|
|
610
611
|
}
|
|
611
612
|
|
|
612
613
|
/**
|
|
@@ -679,7 +680,7 @@ export class WebSocketConnectionManager extends EventEmitter {
|
|
|
679
680
|
* Shutdown connection manager
|
|
680
681
|
*/
|
|
681
682
|
shutdown(): void {
|
|
682
|
-
|
|
683
|
+
liveLog('websocket', null, '🔌 Shutting down WebSocket Connection Manager...')
|
|
683
684
|
|
|
684
685
|
// Clear intervals
|
|
685
686
|
if (this.healthCheckInterval) {
|
|
@@ -701,7 +702,7 @@ export class WebSocketConnectionManager extends EventEmitter {
|
|
|
701
702
|
this.connectionPools.clear()
|
|
702
703
|
this.messageQueues.clear()
|
|
703
704
|
|
|
704
|
-
|
|
705
|
+
liveLog('websocket', null, '✅ WebSocket Connection Manager shutdown complete')
|
|
705
706
|
}
|
|
706
707
|
}
|
|
707
708
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// 🔥 Auto-generated Live Components Registration
|
|
2
2
|
// This file is automatically generated during build time - DO NOT EDIT MANUALLY
|
|
3
|
-
// Generated at: 2026-02-
|
|
3
|
+
// Generated at: 2026-02-21T20:03:53.569Z
|
|
4
4
|
|
|
5
5
|
import { LiveAdminPanel } from "@app/server/live/LiveAdminPanel"
|
|
6
6
|
import { LiveChat } from "@app/server/live/LiveChat"
|
|
@@ -12,6 +12,7 @@ import type { Plugin, PluginContext } from '@core/index'
|
|
|
12
12
|
import { t, Elysia } from 'elysia'
|
|
13
13
|
import path from 'path'
|
|
14
14
|
import { liveLog } from './LiveLogger'
|
|
15
|
+
import { liveDebugger } from './LiveDebugger'
|
|
15
16
|
|
|
16
17
|
// ===== Response Schemas for Live Components Routes =====
|
|
17
18
|
|
|
@@ -116,6 +117,39 @@ const LiveAlertResolveSchema = t.Object({
|
|
|
116
117
|
description: 'Result of alert resolution operation'
|
|
117
118
|
})
|
|
118
119
|
|
|
120
|
+
// 🔒 Per-connection rate limiter to prevent WebSocket message flooding
|
|
121
|
+
class ConnectionRateLimiter {
|
|
122
|
+
private tokens: number
|
|
123
|
+
private lastRefill: number
|
|
124
|
+
private readonly maxTokens: number
|
|
125
|
+
private readonly refillRate: number // tokens per second
|
|
126
|
+
|
|
127
|
+
constructor(maxTokens = 100, refillRate = 50) {
|
|
128
|
+
this.maxTokens = maxTokens
|
|
129
|
+
this.tokens = maxTokens
|
|
130
|
+
this.refillRate = refillRate
|
|
131
|
+
this.lastRefill = Date.now()
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
tryConsume(count = 1): boolean {
|
|
135
|
+
this.refill()
|
|
136
|
+
if (this.tokens >= count) {
|
|
137
|
+
this.tokens -= count
|
|
138
|
+
return true
|
|
139
|
+
}
|
|
140
|
+
return false
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
private refill(): void {
|
|
144
|
+
const now = Date.now()
|
|
145
|
+
const elapsed = (now - this.lastRefill) / 1000
|
|
146
|
+
this.tokens = Math.min(this.maxTokens, this.tokens + elapsed * this.refillRate)
|
|
147
|
+
this.lastRefill = now
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const connectionRateLimiters = new Map<string, ConnectionRateLimiter>()
|
|
152
|
+
|
|
119
153
|
export const liveComponentsPlugin: Plugin = {
|
|
120
154
|
name: 'live-components',
|
|
121
155
|
version: '1.0.0',
|
|
@@ -143,9 +177,12 @@ export const liveComponentsPlugin: Plugin = {
|
|
|
143
177
|
|
|
144
178
|
async open(ws) {
|
|
145
179
|
const socket = ws as unknown as FluxStackWebSocket
|
|
146
|
-
const connectionId = `ws-${
|
|
180
|
+
const connectionId = `ws-${crypto.randomUUID()}`
|
|
147
181
|
liveLog('websocket', null, `🔌 Live Components WebSocket connected: ${connectionId}`)
|
|
148
182
|
|
|
183
|
+
// 🔒 Initialize rate limiter for this connection
|
|
184
|
+
connectionRateLimiters.set(connectionId, new ConnectionRateLimiter())
|
|
185
|
+
|
|
149
186
|
// Register connection with enhanced connection manager
|
|
150
187
|
connectionManager.registerConnection(ws as unknown as FluxStackWebSocket, connectionId, 'live-components')
|
|
151
188
|
|
|
@@ -177,12 +214,19 @@ export const liveComponentsPlugin: Plugin = {
|
|
|
177
214
|
if (authContext.authenticated) {
|
|
178
215
|
socket.data.userId = authContext.user?.id
|
|
179
216
|
liveLog('websocket', null, `🔒 WebSocket authenticated via query: user=${authContext.user?.id}`)
|
|
217
|
+
} else {
|
|
218
|
+
// 🔒 Log failed auth attempts (token was provided but auth failed)
|
|
219
|
+
liveLog('websocket', null, `🔒 WebSocket authentication failed via query token`)
|
|
180
220
|
}
|
|
181
221
|
}
|
|
182
|
-
} catch {
|
|
183
|
-
//
|
|
222
|
+
} catch (authError) {
|
|
223
|
+
// 🔒 Log auth errors instead of silently ignoring them
|
|
224
|
+
console.warn('🔒 WebSocket query auth error:', authError instanceof Error ? authError.message : 'Unknown error')
|
|
184
225
|
}
|
|
185
226
|
|
|
227
|
+
// Debug: track connection
|
|
228
|
+
liveDebugger.trackConnection(connectionId)
|
|
229
|
+
|
|
186
230
|
// Send connection confirmation
|
|
187
231
|
ws.send(JSON.stringify({
|
|
188
232
|
type: 'CONNECTION_ESTABLISHED',
|
|
@@ -203,6 +247,20 @@ export const liveComponentsPlugin: Plugin = {
|
|
|
203
247
|
async message(ws: unknown, rawMessage: LiveMessage | ArrayBuffer | Uint8Array) {
|
|
204
248
|
const socket = ws as FluxStackWebSocket
|
|
205
249
|
try {
|
|
250
|
+
// 🔒 Rate limiting: reject messages if connection exceeds rate limit
|
|
251
|
+
const connId = socket.data?.connectionId
|
|
252
|
+
if (connId) {
|
|
253
|
+
const limiter = connectionRateLimiters.get(connId)
|
|
254
|
+
if (limiter && !limiter.tryConsume()) {
|
|
255
|
+
socket.send(JSON.stringify({
|
|
256
|
+
type: 'ERROR',
|
|
257
|
+
error: 'Rate limit exceeded. Please slow down.',
|
|
258
|
+
timestamp: Date.now()
|
|
259
|
+
}))
|
|
260
|
+
return
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
206
264
|
let message: LiveMessage
|
|
207
265
|
let binaryChunkData: Buffer | null = null
|
|
208
266
|
|
|
@@ -313,6 +371,17 @@ export const liveComponentsPlugin: Plugin = {
|
|
|
313
371
|
const connectionId = socket.data?.connectionId
|
|
314
372
|
liveLog('websocket', null, `🔌 Live Components WebSocket disconnected: ${connectionId}`)
|
|
315
373
|
|
|
374
|
+
// Debug: track disconnection
|
|
375
|
+
const componentCount = socket.data?.components?.size ?? 0
|
|
376
|
+
if (connectionId) {
|
|
377
|
+
liveDebugger.trackDisconnection(connectionId, componentCount)
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// 🔒 Cleanup rate limiter
|
|
381
|
+
if (connectionId) {
|
|
382
|
+
connectionRateLimiters.delete(connectionId)
|
|
383
|
+
}
|
|
384
|
+
|
|
316
385
|
// Cleanup connection in connection manager
|
|
317
386
|
if (connectionId) {
|
|
318
387
|
connectionManager.cleanupConnection(connectionId)
|
|
@@ -511,6 +580,132 @@ export const liveComponentsPlugin: Plugin = {
|
|
|
511
580
|
response: LiveAlertResolveSchema
|
|
512
581
|
})
|
|
513
582
|
|
|
583
|
+
// ===== Live Component Debugger Routes =====
|
|
584
|
+
|
|
585
|
+
// Debug WebSocket - streams debug events in real-time
|
|
586
|
+
.ws('/debug/ws', {
|
|
587
|
+
body: t.Any(),
|
|
588
|
+
|
|
589
|
+
open(ws) {
|
|
590
|
+
const socket = ws as unknown as FluxStackWebSocket
|
|
591
|
+
liveLog('websocket', null, '🔍 Debug client connected')
|
|
592
|
+
liveDebugger.registerDebugClient(socket)
|
|
593
|
+
},
|
|
594
|
+
|
|
595
|
+
message() {
|
|
596
|
+
// Debug clients are read-only, no incoming messages to handle
|
|
597
|
+
},
|
|
598
|
+
|
|
599
|
+
close(ws) {
|
|
600
|
+
const socket = ws as unknown as FluxStackWebSocket
|
|
601
|
+
liveLog('websocket', null, '🔍 Debug client disconnected')
|
|
602
|
+
liveDebugger.unregisterDebugClient(socket)
|
|
603
|
+
}
|
|
604
|
+
})
|
|
605
|
+
|
|
606
|
+
// Debug snapshot - current state of all components
|
|
607
|
+
.get('/debug/snapshot', () => {
|
|
608
|
+
return {
|
|
609
|
+
success: true,
|
|
610
|
+
snapshot: liveDebugger.getSnapshot(),
|
|
611
|
+
timestamp: new Date().toISOString()
|
|
612
|
+
}
|
|
613
|
+
}, {
|
|
614
|
+
detail: {
|
|
615
|
+
summary: 'Debug Snapshot',
|
|
616
|
+
description: 'Returns current state of all active Live Components for debugging',
|
|
617
|
+
tags: ['Live Components', 'Debug']
|
|
618
|
+
}
|
|
619
|
+
})
|
|
620
|
+
|
|
621
|
+
// Debug events - recent event history
|
|
622
|
+
.get('/debug/events', ({ query }) => {
|
|
623
|
+
const filter: { componentId?: string; type?: any; limit?: number } = {}
|
|
624
|
+
if (query.componentId) filter.componentId = query.componentId as string
|
|
625
|
+
if (query.type) filter.type = query.type
|
|
626
|
+
if (query.limit) filter.limit = parseInt(query.limit as string, 10)
|
|
627
|
+
|
|
628
|
+
return {
|
|
629
|
+
success: true,
|
|
630
|
+
events: liveDebugger.getEvents(filter),
|
|
631
|
+
timestamp: new Date().toISOString()
|
|
632
|
+
}
|
|
633
|
+
}, {
|
|
634
|
+
detail: {
|
|
635
|
+
summary: 'Debug Events',
|
|
636
|
+
description: 'Returns recent debug events, optionally filtered by component or type',
|
|
637
|
+
tags: ['Live Components', 'Debug']
|
|
638
|
+
},
|
|
639
|
+
query: t.Object({
|
|
640
|
+
componentId: t.Optional(t.String()),
|
|
641
|
+
type: t.Optional(t.String()),
|
|
642
|
+
limit: t.Optional(t.String())
|
|
643
|
+
})
|
|
644
|
+
})
|
|
645
|
+
|
|
646
|
+
// Debug toggle - enable/disable debugger at runtime
|
|
647
|
+
.post('/debug/toggle', ({ body }) => {
|
|
648
|
+
const enabled = (body as any)?.enabled
|
|
649
|
+
if (typeof enabled === 'boolean') {
|
|
650
|
+
liveDebugger.enabled = enabled
|
|
651
|
+
} else {
|
|
652
|
+
liveDebugger.enabled = !liveDebugger.enabled
|
|
653
|
+
}
|
|
654
|
+
return {
|
|
655
|
+
success: true,
|
|
656
|
+
enabled: liveDebugger.enabled,
|
|
657
|
+
timestamp: new Date().toISOString()
|
|
658
|
+
}
|
|
659
|
+
}, {
|
|
660
|
+
detail: {
|
|
661
|
+
summary: 'Toggle Debugger',
|
|
662
|
+
description: 'Enable or disable the Live Component debugger at runtime',
|
|
663
|
+
tags: ['Live Components', 'Debug']
|
|
664
|
+
}
|
|
665
|
+
})
|
|
666
|
+
|
|
667
|
+
// Debug component state - get specific component state
|
|
668
|
+
.get('/debug/components/:componentId', ({ params }) => {
|
|
669
|
+
const snapshot = liveDebugger.getComponentState(params.componentId)
|
|
670
|
+
if (!snapshot) {
|
|
671
|
+
return { success: false, error: 'Component not found' }
|
|
672
|
+
}
|
|
673
|
+
return {
|
|
674
|
+
success: true,
|
|
675
|
+
component: snapshot,
|
|
676
|
+
events: liveDebugger.getEvents({
|
|
677
|
+
componentId: params.componentId,
|
|
678
|
+
limit: 50
|
|
679
|
+
}),
|
|
680
|
+
timestamp: new Date().toISOString()
|
|
681
|
+
}
|
|
682
|
+
}, {
|
|
683
|
+
detail: {
|
|
684
|
+
summary: 'Debug Component State',
|
|
685
|
+
description: 'Returns current state and recent events for a specific component',
|
|
686
|
+
tags: ['Live Components', 'Debug']
|
|
687
|
+
},
|
|
688
|
+
params: t.Object({
|
|
689
|
+
componentId: t.String()
|
|
690
|
+
})
|
|
691
|
+
})
|
|
692
|
+
|
|
693
|
+
// Clear debug events
|
|
694
|
+
.post('/debug/clear', () => {
|
|
695
|
+
liveDebugger.clearEvents()
|
|
696
|
+
return {
|
|
697
|
+
success: true,
|
|
698
|
+
message: 'Debug events cleared',
|
|
699
|
+
timestamp: new Date().toISOString()
|
|
700
|
+
}
|
|
701
|
+
}, {
|
|
702
|
+
detail: {
|
|
703
|
+
summary: 'Clear Debug Events',
|
|
704
|
+
description: 'Clears the debug event history buffer',
|
|
705
|
+
tags: ['Live Components', 'Debug']
|
|
706
|
+
}
|
|
707
|
+
})
|
|
708
|
+
|
|
514
709
|
// Register the grouped routes with the main app
|
|
515
710
|
context.app.use(liveRoutes)
|
|
516
711
|
},
|
|
@@ -717,9 +912,11 @@ async function handleAuth(ws: FluxStackWebSocket, message: LiveMessage) {
|
|
|
717
912
|
// File Upload Handler Functions
|
|
718
913
|
async function handleFileUploadStart(ws: FluxStackWebSocket, message: FileUploadStartMessage) {
|
|
719
914
|
liveLog('messages', message.componentId || null, '📤 Starting file upload:', message.uploadId)
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
915
|
+
|
|
916
|
+
// 🔒 Pass userId for per-user upload quota enforcement
|
|
917
|
+
const userId = ws.data?.userId || ws.data?.authContext?.user?.id
|
|
918
|
+
const result = await fileUploadManager.startUpload(message, userId)
|
|
919
|
+
|
|
723
920
|
const response = {
|
|
724
921
|
type: 'FILE_UPLOAD_START_RESPONSE',
|
|
725
922
|
componentId: message.componentId,
|
|
@@ -729,7 +926,7 @@ async function handleFileUploadStart(ws: FluxStackWebSocket, message: FileUpload
|
|
|
729
926
|
requestId: message.requestId,
|
|
730
927
|
timestamp: Date.now()
|
|
731
928
|
}
|
|
732
|
-
|
|
929
|
+
|
|
733
930
|
ws.send(JSON.stringify(response))
|
|
734
931
|
}
|
|
735
932
|
|
|
@@ -781,11 +978,23 @@ async function handleRoomJoin(ws: FluxStackWebSocket, message: RoomMessage) {
|
|
|
781
978
|
liveLog('rooms', message.componentId, `🚪 Component ${message.componentId} joining room ${message.roomId}`)
|
|
782
979
|
|
|
783
980
|
try {
|
|
981
|
+
// 🔒 Validate room name format (alphanumeric, hyphens, underscores, max 64 chars)
|
|
982
|
+
if (!message.roomId || !/^[a-zA-Z0-9_:.-]{1,64}$/.test(message.roomId)) {
|
|
983
|
+
throw new Error('Invalid room name. Must be 1-64 alphanumeric characters, hyphens, underscores, dots, or colons.')
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
// 🔒 Room authorization check
|
|
987
|
+
const authContext = ws.data?.authContext || ANONYMOUS_CONTEXT
|
|
988
|
+
const authResult = await liveAuthManager.authorizeRoom(authContext, message.roomId)
|
|
989
|
+
if (!authResult.allowed) {
|
|
990
|
+
throw new Error(`Room access denied: ${authResult.reason}`)
|
|
991
|
+
}
|
|
992
|
+
|
|
784
993
|
const result = liveRoomManager.joinRoom(
|
|
785
994
|
message.componentId,
|
|
786
995
|
message.roomId,
|
|
787
996
|
ws,
|
|
788
|
-
|
|
997
|
+
undefined // 🔒 Don't allow client to set initial room state - server controls this
|
|
789
998
|
)
|
|
790
999
|
|
|
791
1000
|
const response = {
|
|
@@ -843,6 +1052,11 @@ async function handleRoomEmit(ws: FluxStackWebSocket, message: RoomMessage) {
|
|
|
843
1052
|
liveLog('rooms', message.componentId, `📡 Component ${message.componentId} emitting '${message.event}' to room ${message.roomId}`)
|
|
844
1053
|
|
|
845
1054
|
try {
|
|
1055
|
+
// 🔒 Validate room name
|
|
1056
|
+
if (!message.roomId || !/^[a-zA-Z0-9_:.-]{1,64}$/.test(message.roomId)) {
|
|
1057
|
+
throw new Error('Invalid room name')
|
|
1058
|
+
}
|
|
1059
|
+
|
|
846
1060
|
const count = liveRoomManager.emitToRoom(
|
|
847
1061
|
message.roomId,
|
|
848
1062
|
message.event!,
|
|
@@ -866,6 +1080,17 @@ async function handleRoomStateSet(ws: FluxStackWebSocket, message: RoomMessage)
|
|
|
866
1080
|
liveLog('rooms', message.componentId, `📝 Component ${message.componentId} updating state in room ${message.roomId}`)
|
|
867
1081
|
|
|
868
1082
|
try {
|
|
1083
|
+
// 🔒 Validate room name
|
|
1084
|
+
if (!message.roomId || !/^[a-zA-Z0-9_:.-]{1,64}$/.test(message.roomId)) {
|
|
1085
|
+
throw new Error('Invalid room name')
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
// 🔒 Validate state size (max 1MB per update to prevent memory attacks)
|
|
1089
|
+
const stateStr = JSON.stringify(message.data ?? {})
|
|
1090
|
+
if (stateStr.length > 1024 * 1024) {
|
|
1091
|
+
throw new Error('Room state update too large (max 1MB)')
|
|
1092
|
+
}
|
|
1093
|
+
|
|
869
1094
|
liveRoomManager.setRoomState(
|
|
870
1095
|
message.roomId,
|
|
871
1096
|
message.data ?? {},
|