@mantiq/core 0.5.23 → 0.6.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/package.json +1 -1
- package/src/application/Application.ts +79 -10
- package/src/cache/FileCacheStore.ts +35 -3
- package/src/cache/MemoryCacheStore.ts +8 -0
- package/src/exceptions/Handler.ts +10 -2
- package/src/middleware/Cors.ts +21 -11
- package/src/middleware/EncryptCookies.ts +3 -2
- package/src/middleware/StartSession.ts +10 -0
- package/src/middleware/VerifyCsrfToken.ts +12 -7
- package/src/session/SessionManager.ts +2 -1
- package/src/session/Store.ts +6 -1
- package/src/websocket/WebSocketKernel.ts +55 -1
package/package.json
CHANGED
|
@@ -298,14 +298,22 @@ export class Application extends ContainerImpl {
|
|
|
298
298
|
/**
|
|
299
299
|
* Boot all registered (non-deferred) providers.
|
|
300
300
|
* Called after ALL providers have been registered.
|
|
301
|
+
*
|
|
302
|
+
* Errors are re-thrown with descriptive messages so they surface
|
|
303
|
+
* immediately rather than being silently swallowed.
|
|
301
304
|
*/
|
|
302
305
|
async bootProviders(): Promise<void> {
|
|
303
306
|
for (const provider of this.providers) {
|
|
307
|
+
const name = provider.constructor?.name ?? 'Unknown'
|
|
304
308
|
try {
|
|
305
309
|
await provider.boot()
|
|
306
310
|
} catch (e) {
|
|
307
|
-
|
|
308
|
-
|
|
311
|
+
// Re-throw with context so the developer knows which provider failed.
|
|
312
|
+
// Previously errors were only logged, masking critical boot failures.
|
|
313
|
+
throw new Error(
|
|
314
|
+
`[Mantiq] ${name}.boot() failed: ${(e as Error)?.message ?? e}`,
|
|
315
|
+
{ cause: e },
|
|
316
|
+
)
|
|
309
317
|
}
|
|
310
318
|
}
|
|
311
319
|
this.booted = true
|
|
@@ -330,6 +338,10 @@ export class Application extends ContainerImpl {
|
|
|
330
338
|
/**
|
|
331
339
|
* Override make() to handle deferred provider loading.
|
|
332
340
|
* If a binding isn't found in the container, check deferred providers.
|
|
341
|
+
*
|
|
342
|
+
* Deferred providers are registered and booted synchronously where possible.
|
|
343
|
+
* If register() or boot() return a Promise, this method will throw — callers
|
|
344
|
+
* must use makeAsync() for providers that require async initialization.
|
|
333
345
|
*/
|
|
334
346
|
override make<T>(abstract: Bindable<T>): T {
|
|
335
347
|
try {
|
|
@@ -345,17 +357,74 @@ export class Application extends ContainerImpl {
|
|
|
345
357
|
for (const [key, p] of this.deferredProviders.entries()) {
|
|
346
358
|
if (p === deferredProvider) this.deferredProviders.delete(key)
|
|
347
359
|
}
|
|
348
|
-
|
|
349
|
-
const
|
|
360
|
+
|
|
361
|
+
const providerName = deferredProvider.constructor?.name ?? 'DeferredProvider'
|
|
362
|
+
|
|
363
|
+
// Register the deferred provider synchronously. If register()
|
|
364
|
+
// returns a Promise, it means the provider requires async init —
|
|
365
|
+
// we cannot silently fire-and-forget because make() would return
|
|
366
|
+
// before the binding is registered.
|
|
367
|
+
const registerResult = deferredProvider.register()
|
|
368
|
+
if (registerResult && typeof (registerResult as any).then === 'function') {
|
|
369
|
+
throw new Error(
|
|
370
|
+
`[Mantiq] ${providerName}.register() is async but was triggered via synchronous make(). ` +
|
|
371
|
+
`Use await app.makeAsync(${String((abstract as any)?.name ?? abstract)}) instead.`,
|
|
372
|
+
)
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// Boot the deferred provider synchronously.
|
|
376
|
+
const bootResult = deferredProvider.boot()
|
|
377
|
+
if (bootResult && typeof (bootResult as any).then === 'function') {
|
|
378
|
+
throw new Error(
|
|
379
|
+
`[Mantiq] ${providerName}.boot() is async but was triggered via synchronous make(). ` +
|
|
380
|
+
`Use await app.makeAsync(${String((abstract as any)?.name ?? abstract)}) instead.`,
|
|
381
|
+
)
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
this.providers.push(deferredProvider)
|
|
385
|
+
return super.make(abstract)
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
throw err
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Async version of make() that properly awaits deferred provider boot.
|
|
394
|
+
*
|
|
395
|
+
* Use this when the deferred provider's register() or boot() may be async.
|
|
396
|
+
* For synchronous providers, make() is sufficient and faster.
|
|
397
|
+
*/
|
|
398
|
+
async makeAsync<T>(abstract: Bindable<T>): Promise<T> {
|
|
399
|
+
try {
|
|
400
|
+
return super.make(abstract)
|
|
401
|
+
} catch (err) {
|
|
402
|
+
if (
|
|
403
|
+
err instanceof ContainerResolutionError &&
|
|
404
|
+
err.reason === 'not_bound'
|
|
405
|
+
) {
|
|
406
|
+
const deferredProvider = this.deferredProviders.get(abstract)
|
|
407
|
+
if (deferredProvider) {
|
|
408
|
+
// Remove from deferred map so we don't loop
|
|
409
|
+
for (const [key, p] of this.deferredProviders.entries()) {
|
|
410
|
+
if (p === deferredProvider) this.deferredProviders.delete(key)
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const providerName = deferredProvider.constructor?.name ?? 'DeferredProvider'
|
|
414
|
+
|
|
415
|
+
// Await register + boot so the binding is fully available
|
|
416
|
+
// before we resolve it. Errors propagate to the caller.
|
|
417
|
+
try {
|
|
350
418
|
await deferredProvider.register()
|
|
351
419
|
await deferredProvider.boot()
|
|
352
|
-
|
|
420
|
+
} catch (e) {
|
|
421
|
+
throw new Error(
|
|
422
|
+
`[Mantiq] ${providerName} deferred boot failed: ${(e as Error)?.message ?? e}`,
|
|
423
|
+
{ cause: e },
|
|
424
|
+
)
|
|
353
425
|
}
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
const name = deferredProvider.constructor?.name ?? 'DeferredProvider'
|
|
357
|
-
console.error(`[Mantiq] ${name} deferred boot failed:`, (e as Error)?.stack ?? e)
|
|
358
|
-
})
|
|
426
|
+
|
|
427
|
+
this.providers.push(deferredProvider)
|
|
359
428
|
return super.make(abstract)
|
|
360
429
|
}
|
|
361
430
|
}
|
|
@@ -81,10 +81,42 @@ export class FileCacheStore implements CacheStore {
|
|
|
81
81
|
}
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
+
/**
|
|
85
|
+
* Fix #194: Atomic increment using write-to-temp + rename to prevent
|
|
86
|
+
* TOCTOU race conditions where concurrent increments could lose updates.
|
|
87
|
+
*/
|
|
84
88
|
async increment(key: string, value = 1): Promise<number> {
|
|
85
|
-
|
|
86
|
-
const
|
|
87
|
-
|
|
89
|
+
await this.ensureDirectory()
|
|
90
|
+
const targetPath = this.path(key)
|
|
91
|
+
const file = Bun.file(targetPath)
|
|
92
|
+
|
|
93
|
+
let current = 0
|
|
94
|
+
if (await file.exists()) {
|
|
95
|
+
try {
|
|
96
|
+
const payload: FileCachePayload = await file.json()
|
|
97
|
+
if (payload.expiresAt !== null && Date.now() > payload.expiresAt) {
|
|
98
|
+
// Expired — treat as 0
|
|
99
|
+
} else {
|
|
100
|
+
current = (payload.value as number) ?? 0
|
|
101
|
+
}
|
|
102
|
+
} catch {
|
|
103
|
+
// Corrupted file — start from 0
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const newValue = current + value
|
|
108
|
+
|
|
109
|
+
// Atomic write: temp file + rename to avoid partial reads by concurrent operations
|
|
110
|
+
const tmpPath = join(this.directory, `_tmp_${randomBytes(8).toString('hex')}.cache`)
|
|
111
|
+
const payload: FileCachePayload = { value: newValue, expiresAt: null }
|
|
112
|
+
await Bun.write(tmpPath, JSON.stringify(payload))
|
|
113
|
+
|
|
114
|
+
try {
|
|
115
|
+
await rename(tmpPath, targetPath)
|
|
116
|
+
} catch {
|
|
117
|
+
try { await rm(tmpPath) } catch { /* ignore */ }
|
|
118
|
+
}
|
|
119
|
+
|
|
88
120
|
return newValue
|
|
89
121
|
}
|
|
90
122
|
|
|
@@ -43,6 +43,14 @@ export class MemoryCacheStore implements CacheStore {
|
|
|
43
43
|
this.store.clear()
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
/**
|
|
47
|
+
* Increment a cached numeric value.
|
|
48
|
+
*
|
|
49
|
+
* Note (#194): This get-then-put is safe in single-threaded Bun because
|
|
50
|
+
* there is no I/O between the read and write — the event loop cannot
|
|
51
|
+
* yield to another task mid-execution. If Bun gains multi-threaded
|
|
52
|
+
* workers sharing memory, this would need a lock or atomic operation.
|
|
53
|
+
*/
|
|
46
54
|
async increment(key: string, value = 1): Promise<number> {
|
|
47
55
|
const current = await this.get<number>(key)
|
|
48
56
|
const newValue = (current ?? 0) + value
|
|
@@ -8,6 +8,11 @@ import { UnauthorizedError } from '../errors/UnauthorizedError.ts'
|
|
|
8
8
|
import { MantiqResponse } from '../http/Response.ts'
|
|
9
9
|
import { renderDevErrorPage } from './DevErrorPage.ts'
|
|
10
10
|
|
|
11
|
+
/** Security: escape HTML special characters to prevent XSS in error pages. */
|
|
12
|
+
function escapeHtml(s: string): string {
|
|
13
|
+
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''')
|
|
14
|
+
}
|
|
15
|
+
|
|
11
16
|
export class DefaultExceptionHandler implements ExceptionHandler {
|
|
12
17
|
dontReport: Constructor<Error>[] = [
|
|
13
18
|
NotFoundError,
|
|
@@ -102,12 +107,15 @@ export class DefaultExceptionHandler implements ExceptionHandler {
|
|
|
102
107
|
}
|
|
103
108
|
|
|
104
109
|
private genericHtmlPage(status: number, message: string): string {
|
|
110
|
+
// Security: escape message to prevent XSS — error messages may contain
|
|
111
|
+
// user-controlled input (e.g., URL paths, query parameters).
|
|
112
|
+
const safeMessage = escapeHtml(message)
|
|
105
113
|
return `<!DOCTYPE html>
|
|
106
114
|
<html lang="en">
|
|
107
115
|
<head>
|
|
108
116
|
<meta charset="UTF-8">
|
|
109
117
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
110
|
-
<title>${status} ${
|
|
118
|
+
<title>${status} ${safeMessage}</title>
|
|
111
119
|
<style>
|
|
112
120
|
body { font-family: system-ui, sans-serif; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; background: #f7fafc; color: #2d3748; }
|
|
113
121
|
.box { text-align: center; }
|
|
@@ -118,7 +126,7 @@ export class DefaultExceptionHandler implements ExceptionHandler {
|
|
|
118
126
|
<body>
|
|
119
127
|
<div class="box">
|
|
120
128
|
<h1>${status}</h1>
|
|
121
|
-
<p>${
|
|
129
|
+
<p>${safeMessage}</p>
|
|
122
130
|
</div>
|
|
123
131
|
</body>
|
|
124
132
|
</html>`
|
package/src/middleware/Cors.ts
CHANGED
|
@@ -24,12 +24,26 @@ export class CorsMiddleware implements Middleware {
|
|
|
24
24
|
const defaultOrigin = appUrl || '*'
|
|
25
25
|
const defaultCredentials = !!appUrl
|
|
26
26
|
|
|
27
|
+
let origin = configRepo?.get('cors.origin', defaultOrigin) ?? defaultOrigin
|
|
28
|
+
let credentials = configRepo?.get('cors.credentials', defaultCredentials) ?? defaultCredentials
|
|
29
|
+
|
|
30
|
+
// Security: origin='*' with credentials=true is dangerous — it effectively
|
|
31
|
+
// disables same-origin policy by reflecting any request origin. Force
|
|
32
|
+
// credentials to false when origin is a wildcard.
|
|
33
|
+
if (origin === '*' && credentials) {
|
|
34
|
+
console.warn(
|
|
35
|
+
'[Mantiq] CORS: origin="*" with credentials=true is insecure. ' +
|
|
36
|
+
'Forcing credentials=false. Set an explicit origin to use credentials.',
|
|
37
|
+
)
|
|
38
|
+
credentials = false
|
|
39
|
+
}
|
|
40
|
+
|
|
27
41
|
this.config = {
|
|
28
|
-
origin
|
|
42
|
+
origin,
|
|
29
43
|
methods: configRepo?.get('cors.methods', ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS']) ?? ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
|
30
44
|
allowedHeaders: configRepo?.get('cors.allowedHeaders', ['Content-Type', 'Authorization', 'X-Requested-With', 'X-CSRF-TOKEN', 'X-XSRF-TOKEN', 'X-Mantiq']) ?? ['Content-Type', 'Authorization', 'X-Requested-With', 'X-CSRF-TOKEN', 'X-XSRF-TOKEN', 'X-Mantiq'],
|
|
31
45
|
exposedHeaders: configRepo?.get('cors.exposedHeaders', ['X-Heartbeat']) ?? ['X-Heartbeat'],
|
|
32
|
-
credentials
|
|
46
|
+
credentials,
|
|
33
47
|
maxAge: configRepo?.get('cors.maxAge', 7200) ?? 7200,
|
|
34
48
|
}
|
|
35
49
|
}
|
|
@@ -76,15 +90,11 @@ export class CorsMiddleware implements Middleware {
|
|
|
76
90
|
private setOriginHeader(headers: Headers, requestOrigin: string): void {
|
|
77
91
|
const { origin } = this.config
|
|
78
92
|
if (origin === '*') {
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
headers.set('Vary', 'Origin')
|
|
85
|
-
} else {
|
|
86
|
-
headers.set('Access-Control-Allow-Origin', '*')
|
|
87
|
-
}
|
|
93
|
+
// When origin='*', always use the literal wildcard. The constructor
|
|
94
|
+
// ensures credentials is false when origin='*', so this is spec-safe.
|
|
95
|
+
// We never reflect an arbitrary request origin — that would defeat
|
|
96
|
+
// same-origin policy when combined with credentials.
|
|
97
|
+
headers.set('Access-Control-Allow-Origin', '*')
|
|
88
98
|
} else if (Array.isArray(origin)) {
|
|
89
99
|
if (origin.includes(requestOrigin)) {
|
|
90
100
|
headers.set('Access-Control-Allow-Origin', requestOrigin)
|
|
@@ -97,8 +97,9 @@ export class EncryptCookies implements Middleware {
|
|
|
97
97
|
|
|
98
98
|
headers.append('Set-Cookie', newSetCookie)
|
|
99
99
|
} catch {
|
|
100
|
-
//
|
|
101
|
-
|
|
100
|
+
// Security: drop the cookie entirely rather than sending it unencrypted.
|
|
101
|
+
// Sending plaintext cookies would expose sensitive data on the wire.
|
|
102
|
+
console.warn(`[Mantiq] Failed to encrypt cookie "${name}" — dropping cookie to prevent plaintext exposure.`)
|
|
102
103
|
}
|
|
103
104
|
}
|
|
104
105
|
|
|
@@ -55,6 +55,16 @@ export class StartSession implements Middleware {
|
|
|
55
55
|
// Save session
|
|
56
56
|
await session.save()
|
|
57
57
|
|
|
58
|
+
// Probabilistic garbage collection: ~2% chance per request to clean up expired sessions.
|
|
59
|
+
// This avoids the need for a dedicated cron job while keeping overhead minimal.
|
|
60
|
+
if (Math.random() < 0.02) {
|
|
61
|
+
const lifetime = config.lifetime * 60 // convert minutes to seconds
|
|
62
|
+
handler.gc(lifetime).catch(() => {
|
|
63
|
+
// GC failures are non-critical — log and continue
|
|
64
|
+
console.warn('[Mantiq] Session garbage collection failed.')
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
|
|
58
68
|
// Attach cookie to response
|
|
59
69
|
return this.addCookieToResponse(response, session, config)
|
|
60
70
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Middleware, NextFunction } from '../contracts/Middleware.ts'
|
|
2
2
|
import type { MantiqRequest } from '../contracts/Request.ts'
|
|
3
|
+
import { timingSafeEqual as cryptoTimingSafeEqual } from 'node:crypto'
|
|
3
4
|
import { TokenMismatchError } from '../errors/TokenMismatchError.ts'
|
|
4
5
|
import { AesEncrypter } from '../encryption/Encrypter.ts'
|
|
5
6
|
import { serializeCookie } from '../http/Cookie.ts'
|
|
@@ -113,17 +114,21 @@ export class VerifyCsrfToken implements Middleware {
|
|
|
113
114
|
|
|
114
115
|
/**
|
|
115
116
|
* Constant-time string comparison to prevent timing attacks on token verification.
|
|
117
|
+
* Uses node:crypto's timingSafeEqual which handles length-mismatch internally
|
|
118
|
+
* without leaking token length via timing side-channels.
|
|
116
119
|
*/
|
|
117
120
|
function timingSafeEqual(a: string, b: string): boolean {
|
|
118
|
-
if (a.length !== b.length) return false
|
|
119
|
-
|
|
120
121
|
const encoder = new TextEncoder()
|
|
121
122
|
const bufA = encoder.encode(a)
|
|
122
123
|
const bufB = encoder.encode(b)
|
|
123
124
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
125
|
+
// Pad to equal length so we never leak the expected token length via an early return.
|
|
126
|
+
const maxLen = Math.max(bufA.length, bufB.length)
|
|
127
|
+
const paddedA = new Uint8Array(maxLen)
|
|
128
|
+
const paddedB = new Uint8Array(maxLen)
|
|
129
|
+
paddedA.set(bufA)
|
|
130
|
+
paddedB.set(bufB)
|
|
131
|
+
|
|
132
|
+
// If lengths differ the tokens cannot match, but we still compare in constant time.
|
|
133
|
+
return bufA.length === bufB.length && cryptoTimingSafeEqual(paddedA, paddedB)
|
|
129
134
|
}
|
|
@@ -9,7 +9,8 @@ const SESSION_DEFAULTS: SessionConfig = {
|
|
|
9
9
|
lifetime: 120,
|
|
10
10
|
cookie: 'mantiq_session',
|
|
11
11
|
path: '/',
|
|
12
|
-
|
|
12
|
+
// Security: default to secure cookies (HTTPS-only). Override in dev config for HTTP.
|
|
13
|
+
secure: true,
|
|
13
14
|
httpOnly: true,
|
|
14
15
|
sameSite: 'Lax',
|
|
15
16
|
}
|
package/src/session/Store.ts
CHANGED
|
@@ -162,6 +162,9 @@ export class SessionStore {
|
|
|
162
162
|
await this.handler.destroy(this.id)
|
|
163
163
|
}
|
|
164
164
|
this.id = SessionStore.generateId()
|
|
165
|
+
// Security: regenerate the CSRF token alongside the session ID to prevent
|
|
166
|
+
// token fixation attacks (e.g. after login).
|
|
167
|
+
this.regenerateToken()
|
|
165
168
|
}
|
|
166
169
|
|
|
167
170
|
/**
|
|
@@ -182,7 +185,9 @@ export class SessionStore {
|
|
|
182
185
|
// ── Static helpers ──────────────────────────────────────────────────────
|
|
183
186
|
|
|
184
187
|
static generateId(): string {
|
|
185
|
-
|
|
188
|
+
// Security: use 256 bits (32 bytes) of entropy per OWASP session ID guidelines.
|
|
189
|
+
// Produces a 64-char hex string.
|
|
190
|
+
const bytes = new Uint8Array(32)
|
|
186
191
|
crypto.getRandomValues(bytes)
|
|
187
192
|
let id = ''
|
|
188
193
|
for (let i = 0; i < bytes.length; i++) {
|
|
@@ -8,11 +8,30 @@ import { parseCookies } from '../http/Cookie.ts'
|
|
|
8
8
|
*
|
|
9
9
|
* Core only provides the infrastructure — @mantiq/realtime registers
|
|
10
10
|
* its handler via registerHandler(). Without it, all upgrades return 426.
|
|
11
|
+
*
|
|
12
|
+
* IMPORTANT: WebSocket upgrade requests bypass the HTTP middleware pipeline
|
|
13
|
+
* entirely. This means middleware like VerifyCsrfToken, StartSession, and
|
|
14
|
+
* CorsMiddleware do NOT run on WebSocket connections. Authentication must
|
|
15
|
+
* be handled in the onUpgrade hook (via the registered WebSocketHandler's
|
|
16
|
+
* `onUpgrade` method). Cookie decryption is handled manually below.
|
|
17
|
+
*
|
|
18
|
+
* If you need additional request validation on upgrade, register an
|
|
19
|
+
* `onUpgrade` callback in your WebSocketHandler implementation that
|
|
20
|
+
* performs the necessary checks (e.g., origin verification, rate limiting).
|
|
11
21
|
*/
|
|
12
22
|
export class WebSocketKernel {
|
|
13
23
|
private handler: WebSocketHandler | null = null
|
|
14
24
|
private encrypter: AesEncrypter | null = null
|
|
15
25
|
|
|
26
|
+
/**
|
|
27
|
+
* Optional hooks that run during the upgrade request before the handler's
|
|
28
|
+
* onUpgrade. Use this to add origin checks, rate limiting, or other
|
|
29
|
+
* validation that would normally be done by HTTP middleware.
|
|
30
|
+
*
|
|
31
|
+
* Return a Response to reject the upgrade, or void/undefined to continue.
|
|
32
|
+
*/
|
|
33
|
+
private upgradeHooks: Array<(request: MantiqRequest) => Promise<Response | void>> = []
|
|
34
|
+
|
|
16
35
|
/**
|
|
17
36
|
* Called by @mantiq/realtime to register its WebSocket handler.
|
|
18
37
|
*/
|
|
@@ -20,6 +39,18 @@ export class WebSocketKernel {
|
|
|
20
39
|
this.handler = handler
|
|
21
40
|
}
|
|
22
41
|
|
|
42
|
+
/**
|
|
43
|
+
* Register a hook that runs on every WebSocket upgrade request.
|
|
44
|
+
* Since WebSocket upgrades bypass the HTTP middleware pipeline,
|
|
45
|
+
* use this to add checks that middleware would normally handle
|
|
46
|
+
* (e.g., origin validation, rate limiting, auth).
|
|
47
|
+
*
|
|
48
|
+
* Return a Response to reject the upgrade, or void to continue.
|
|
49
|
+
*/
|
|
50
|
+
onUpgrade(hook: (request: MantiqRequest) => Promise<Response | void>): void {
|
|
51
|
+
this.upgradeHooks.push(hook)
|
|
52
|
+
}
|
|
53
|
+
|
|
23
54
|
/**
|
|
24
55
|
* Inject the encrypter so WebSocket upgrades can decrypt cookies.
|
|
25
56
|
* Called during CoreServiceProvider boot.
|
|
@@ -30,6 +61,9 @@ export class WebSocketKernel {
|
|
|
30
61
|
|
|
31
62
|
/**
|
|
32
63
|
* Called by HttpKernel when an upgrade request is detected.
|
|
64
|
+
*
|
|
65
|
+
* NOTE: The HTTP middleware pipeline does NOT run for WebSocket upgrades.
|
|
66
|
+
* Authentication is delegated to the handler's onUpgrade method.
|
|
33
67
|
*/
|
|
34
68
|
async handleUpgrade(request: Request, server: any): Promise<Response> {
|
|
35
69
|
if (!this.handler) {
|
|
@@ -47,6 +81,15 @@ export class WebSocketKernel {
|
|
|
47
81
|
await this.decryptCookies(mantiqRequest)
|
|
48
82
|
}
|
|
49
83
|
|
|
84
|
+
// Run onUpgrade hooks — these substitute for HTTP middleware that
|
|
85
|
+
// would normally run (auth, origin checks, rate limiting, etc.).
|
|
86
|
+
for (const hook of this.upgradeHooks) {
|
|
87
|
+
const rejection = await hook(mantiqRequest)
|
|
88
|
+
if (rejection instanceof Response) {
|
|
89
|
+
return rejection
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
50
93
|
const context = await this.handler.onUpgrade(mantiqRequest)
|
|
51
94
|
|
|
52
95
|
if (!context) {
|
|
@@ -65,15 +108,26 @@ export class WebSocketKernel {
|
|
|
65
108
|
/**
|
|
66
109
|
* Returns the Bun WebSocket handlers object for Bun.serve().
|
|
67
110
|
* If no handler is registered, provides no-op stubs.
|
|
111
|
+
*
|
|
112
|
+
* Includes maxPayloadLength from the handler if available, which tells
|
|
113
|
+
* Bun to enforce a transport-level message size limit.
|
|
68
114
|
*/
|
|
69
115
|
getBunHandlers(): object {
|
|
70
116
|
const h = this.handler
|
|
71
|
-
|
|
117
|
+
const handlers: Record<string, any> = {
|
|
72
118
|
open: (ws: any) => h?.open(ws),
|
|
73
119
|
message: (ws: any, msg: any) => h?.message(ws, msg),
|
|
74
120
|
close: (ws: any, code: number, reason: string) => h?.close(ws, code, reason),
|
|
75
121
|
drain: (ws: any) => h?.drain(ws),
|
|
76
122
|
}
|
|
123
|
+
|
|
124
|
+
// Pass maxPayloadLength to Bun's WebSocket config if the handler provides it.
|
|
125
|
+
// This enforces a transport-level limit on incoming message sizes.
|
|
126
|
+
if (h && typeof (h as any).getMaxPayloadLength === 'function') {
|
|
127
|
+
handlers.maxPayloadLength = (h as any).getMaxPayloadLength()
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return handlers
|
|
77
131
|
}
|
|
78
132
|
|
|
79
133
|
// ── Private ───────────────────────────────────────────────────────────────
|