@mantiq/core 0.5.22 → 0.6.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/package.json +1 -1
- package/src/application/Application.ts +132 -10
- package/src/cache/FileCacheStore.ts +73 -9
- package/src/cache/MemoryCacheStore.ts +8 -0
- package/src/contracts/Request.ts +3 -2
- package/src/contracts/Router.ts +2 -0
- package/src/encryption/errors.ts +5 -2
- package/src/errors/ConfigKeyNotFoundError.ts +6 -1
- package/src/errors/ContainerResolutionError.ts +3 -0
- package/src/errors/ErrorCodes.ts +27 -0
- package/src/errors/ForbiddenError.ts +2 -1
- package/src/errors/HttpError.ts +4 -1
- package/src/errors/MantiqError.ts +12 -1
- package/src/errors/NotFoundError.ts +2 -1
- package/src/errors/TokenMismatchError.ts +2 -1
- package/src/errors/TooManyRequestsError.ts +2 -1
- package/src/errors/UnauthorizedError.ts +2 -1
- package/src/errors/ValidationError.ts +2 -1
- package/src/exceptions/Handler.ts +10 -2
- package/src/helpers/signedUrl.ts +26 -0
- package/src/helpers/url.ts +31 -0
- package/src/http/Kernel.ts +56 -0
- package/src/http/Request.ts +9 -5
- package/src/index.ts +9 -0
- package/src/middleware/Cors.ts +21 -11
- package/src/middleware/EncryptCookies.ts +15 -5
- package/src/middleware/RouteModelBinding.ts +43 -0
- package/src/middleware/StartSession.ts +10 -0
- package/src/middleware/TimeoutMiddleware.ts +47 -0
- package/src/middleware/VerifyCsrfToken.ts +12 -7
- package/src/providers/CoreServiceProvider.ts +26 -0
- package/src/routing/Route.ts +11 -0
- package/src/routing/Router.ts +32 -1
- package/src/session/SessionManager.ts +2 -1
- package/src/session/Store.ts +6 -1
- package/src/url/UrlSigner.ts +131 -0
- package/src/websocket/WebSocketKernel.ts +58 -2
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import type { AesEncrypter } from '../encryption/Encrypter.ts'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* URL signing utility.
|
|
5
|
+
*
|
|
6
|
+
* Generates signed URLs with HMAC-based signatures so you can create links
|
|
7
|
+
* that prove they haven't been tampered with (e.g. email verification links,
|
|
8
|
+
* temporary download URLs).
|
|
9
|
+
*
|
|
10
|
+
* Uses the application's AesEncrypter key to derive HMAC-SHA256 signatures.
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* ```ts
|
|
14
|
+
* const signer = new UrlSigner(encrypter)
|
|
15
|
+
* const signed = await signer.sign('https://example.com/verify?user=42')
|
|
16
|
+
* const isValid = await signer.validate(signed) // true
|
|
17
|
+
*
|
|
18
|
+
* const temp = await signer.temporarySignedUrl('https://example.com/download/file.zip', 60)
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
export class UrlSigner {
|
|
22
|
+
private signingKey: CryptoKey | null = null
|
|
23
|
+
|
|
24
|
+
constructor(private readonly encrypter: AesEncrypter) {}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Sign a URL by appending a signature (and optional expiration) as query parameters.
|
|
28
|
+
*/
|
|
29
|
+
async sign(url: string, expiresAt?: Date): Promise<string> {
|
|
30
|
+
const parsed = new URL(url)
|
|
31
|
+
|
|
32
|
+
// Remove any existing signature params to prevent double-signing
|
|
33
|
+
parsed.searchParams.delete('signature')
|
|
34
|
+
parsed.searchParams.delete('expires')
|
|
35
|
+
|
|
36
|
+
if (expiresAt) {
|
|
37
|
+
parsed.searchParams.set('expires', String(Math.floor(expiresAt.getTime() / 1000)))
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const signature = await this.createSignature(parsed.toString())
|
|
41
|
+
parsed.searchParams.set('signature', signature)
|
|
42
|
+
|
|
43
|
+
return parsed.toString()
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Validate a signed URL — verify the signature and check expiration.
|
|
48
|
+
*/
|
|
49
|
+
async validate(url: string): Promise<boolean> {
|
|
50
|
+
const parsed = new URL(url)
|
|
51
|
+
|
|
52
|
+
const signature = parsed.searchParams.get('signature')
|
|
53
|
+
if (!signature) return false
|
|
54
|
+
|
|
55
|
+
// Check expiration before verifying signature
|
|
56
|
+
const expires = parsed.searchParams.get('expires')
|
|
57
|
+
if (expires) {
|
|
58
|
+
const expiresAt = Number(expires) * 1000
|
|
59
|
+
if (Date.now() > expiresAt) return false
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Reconstruct the URL without the signature to verify
|
|
63
|
+
parsed.searchParams.delete('signature')
|
|
64
|
+
const expectedSignature = await this.createSignature(parsed.toString())
|
|
65
|
+
|
|
66
|
+
return timingSafeEqual(signature, expectedSignature)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Create a temporary signed URL that expires after the given number of minutes.
|
|
71
|
+
*/
|
|
72
|
+
async temporarySignedUrl(url: string, minutes: number): Promise<string> {
|
|
73
|
+
return this.sign(url, new Date(Date.now() + minutes * 60_000))
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ── Internal ────────────────────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Derive the HMAC signing key from the encrypter's raw AES key.
|
|
80
|
+
*/
|
|
81
|
+
private async getSigningKey(): Promise<CryptoKey> {
|
|
82
|
+
if (this.signingKey) return this.signingKey
|
|
83
|
+
|
|
84
|
+
this.signingKey = await crypto.subtle.importKey(
|
|
85
|
+
'raw',
|
|
86
|
+
this.encrypter.getKey() as ArrayBuffer,
|
|
87
|
+
{ name: 'HMAC', hash: 'SHA-256' },
|
|
88
|
+
false,
|
|
89
|
+
['sign', 'verify'],
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
return this.signingKey
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Create an HMAC-SHA256 signature for the given data string.
|
|
97
|
+
*/
|
|
98
|
+
private async createSignature(data: string): Promise<string> {
|
|
99
|
+
const key = await this.getSigningKey()
|
|
100
|
+
const encoded = new TextEncoder().encode(data)
|
|
101
|
+
const signature = await crypto.subtle.sign('HMAC', key, encoded)
|
|
102
|
+
return encodeHex(new Uint8Array(signature))
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ── Helpers ────────────────────────────────────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
function encodeHex(bytes: Uint8Array): string {
|
|
109
|
+
let hex = ''
|
|
110
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
111
|
+
hex += bytes[i]!.toString(16).padStart(2, '0')
|
|
112
|
+
}
|
|
113
|
+
return hex
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Constant-time string comparison to prevent timing attacks on signature verification.
|
|
118
|
+
*/
|
|
119
|
+
function timingSafeEqual(a: string, b: string): boolean {
|
|
120
|
+
if (a.length !== b.length) return false
|
|
121
|
+
|
|
122
|
+
const encoder = new TextEncoder()
|
|
123
|
+
const bufA = encoder.encode(a)
|
|
124
|
+
const bufB = encoder.encode(b)
|
|
125
|
+
|
|
126
|
+
let result = 0
|
|
127
|
+
for (let i = 0; i < bufA.length; i++) {
|
|
128
|
+
result |= bufA[i]! ^ bufB[i]!
|
|
129
|
+
}
|
|
130
|
+
return result === 0
|
|
131
|
+
}
|
|
@@ -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 ───────────────────────────────────────────────────────────────
|
|
@@ -94,7 +148,9 @@ export class WebSocketKernel {
|
|
|
94
148
|
try {
|
|
95
149
|
decrypted[name] = await this.encrypter!.decrypt(value)
|
|
96
150
|
} catch {
|
|
97
|
-
|
|
151
|
+
// Can't decrypt — expired key, tampered, or wrong format.
|
|
152
|
+
// Don't pass through the encrypted blob; discard it.
|
|
153
|
+
decrypted[name] = ''
|
|
98
154
|
}
|
|
99
155
|
}
|
|
100
156
|
|