@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.
Files changed (37) hide show
  1. package/package.json +1 -1
  2. package/src/application/Application.ts +132 -10
  3. package/src/cache/FileCacheStore.ts +73 -9
  4. package/src/cache/MemoryCacheStore.ts +8 -0
  5. package/src/contracts/Request.ts +3 -2
  6. package/src/contracts/Router.ts +2 -0
  7. package/src/encryption/errors.ts +5 -2
  8. package/src/errors/ConfigKeyNotFoundError.ts +6 -1
  9. package/src/errors/ContainerResolutionError.ts +3 -0
  10. package/src/errors/ErrorCodes.ts +27 -0
  11. package/src/errors/ForbiddenError.ts +2 -1
  12. package/src/errors/HttpError.ts +4 -1
  13. package/src/errors/MantiqError.ts +12 -1
  14. package/src/errors/NotFoundError.ts +2 -1
  15. package/src/errors/TokenMismatchError.ts +2 -1
  16. package/src/errors/TooManyRequestsError.ts +2 -1
  17. package/src/errors/UnauthorizedError.ts +2 -1
  18. package/src/errors/ValidationError.ts +2 -1
  19. package/src/exceptions/Handler.ts +10 -2
  20. package/src/helpers/signedUrl.ts +26 -0
  21. package/src/helpers/url.ts +31 -0
  22. package/src/http/Kernel.ts +56 -0
  23. package/src/http/Request.ts +9 -5
  24. package/src/index.ts +9 -0
  25. package/src/middleware/Cors.ts +21 -11
  26. package/src/middleware/EncryptCookies.ts +15 -5
  27. package/src/middleware/RouteModelBinding.ts +43 -0
  28. package/src/middleware/StartSession.ts +10 -0
  29. package/src/middleware/TimeoutMiddleware.ts +47 -0
  30. package/src/middleware/VerifyCsrfToken.ts +12 -7
  31. package/src/providers/CoreServiceProvider.ts +26 -0
  32. package/src/routing/Route.ts +11 -0
  33. package/src/routing/Router.ts +32 -1
  34. package/src/session/SessionManager.ts +2 -1
  35. package/src/session/Store.ts +6 -1
  36. package/src/url/UrlSigner.ts +131 -0
  37. 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
- return {
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
- decrypted[name] = value
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