@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
@@ -56,6 +56,20 @@ export class HttpKernel {
56
56
  this.globalMiddleware = middleware
57
57
  }
58
58
 
59
+ /**
60
+ * Check whether a middleware alias has been registered.
61
+ */
62
+ hasMiddleware(alias: string): boolean {
63
+ return alias in this.middlewareAliases
64
+ }
65
+
66
+ /**
67
+ * Return all registered middleware alias names.
68
+ */
69
+ getRegisteredAliases(): string[] {
70
+ return Object.keys(this.middlewareAliases)
71
+ }
72
+
59
73
  /**
60
74
  * Middleware registered by packages that run before the app's global middleware.
61
75
  * Separate from globalMiddleware so setGlobalMiddleware() doesn't overwrite them.
@@ -202,11 +216,53 @@ export class HttpKernel {
202
216
 
203
217
  // ── Private ───────────────────────────────────────────────────────────────
204
218
 
219
+ /**
220
+ * Resolve route model bindings.
221
+ * Replaces raw parameter values (e.g. '42') with model instances.
222
+ * Returns a 404 response if any bound model is not found.
223
+ */
224
+ private async resolveBindings(
225
+ match: RouteMatch,
226
+ request: MantiqRequestContract,
227
+ ): Promise<Response | null> {
228
+ if (!match.bindings || match.bindings.size === 0) return null
229
+
230
+ for (const [param, { model, key }] of match.bindings) {
231
+ const value = request.param(param)
232
+ if (value === undefined) continue
233
+
234
+ let instance: any
235
+
236
+ if (key === '__custom__') {
237
+ // Custom resolver function (from router.bind())
238
+ instance = await model(String(value))
239
+ } else {
240
+ // Model class with .where().first()
241
+ instance = await model.where(key, value).first()
242
+ }
243
+
244
+ if (!instance) {
245
+ return new Response(
246
+ JSON.stringify({ error: `${param} not found.` }),
247
+ { status: 404, headers: { 'Content-Type': 'application/json' } },
248
+ )
249
+ }
250
+
251
+ request.setRouteParam(param, instance)
252
+ }
253
+
254
+ return null
255
+ }
256
+
205
257
  /**
206
258
  * Call the route action (controller method or closure).
207
259
  * Converts the return value to a Response.
208
260
  */
209
261
  private async callAction(match: RouteMatch, request: MantiqRequestContract): Promise<Response> {
262
+ // Resolve model bindings before calling the action
263
+ const bindingError = await this.resolveBindings(match, request)
264
+ if (bindingError) return bindingError
265
+
210
266
  const action = match.action
211
267
 
212
268
  let result: any
@@ -58,15 +58,15 @@ export class MantiqRequest implements MantiqRequestContract {
58
58
  return this.parsedQuery[key] ?? defaultValue ?? (undefined as any)
59
59
  }
60
60
 
61
- async input(): Promise<Record<string, any>>
62
- async input(key: string, defaultValue?: any): Promise<any>
63
- async input(key?: string, defaultValue?: any): Promise<any> {
61
+ async input<T = Record<string, any>>(): Promise<T>
62
+ async input<T = any>(key: string, defaultValue?: any): Promise<T>
63
+ async input<T = any>(key?: string, defaultValue?: any): Promise<T> {
64
64
  if (!this.parsedBody) {
65
65
  await this.parseBody()
66
66
  }
67
67
  const merged = { ...this.query(), ...this.parsedBody }
68
- if (key === undefined) return merged
69
- return merged[key] ?? defaultValue
68
+ if (key === undefined) return merged as T
69
+ return (merged[key] ?? defaultValue) as T
70
70
  }
71
71
 
72
72
  async only(...keys: string[]): Promise<Record<string, any>> {
@@ -211,6 +211,10 @@ export class MantiqRequest implements MantiqRequestContract {
211
211
  this.routeParams = params
212
212
  }
213
213
 
214
+ setRouteParam(key: string, value: any): void {
215
+ this.routeParams[key] = value
216
+ }
217
+
214
218
  // ── Session ──────────────────────────────────────────────────────────────
215
219
 
216
220
  session(): SessionStore {
package/src/index.ts CHANGED
@@ -24,6 +24,8 @@ export { Event, Listener } from './contracts/EventDispatcher.ts'
24
24
  export type { WebSocketHandler, WebSocketContext } from './websocket/WebSocketContext.ts'
25
25
 
26
26
  // ── Errors ────────────────────────────────────────────────────────────────────
27
+ export { ErrorCodes } from './errors/ErrorCodes.ts'
28
+ export type { ErrorCode } from './errors/ErrorCodes.ts'
27
29
  export { MantiqError } from './errors/MantiqError.ts'
28
30
  export { HttpError } from './errors/HttpError.ts'
29
31
  export { NotFoundError } from './errors/NotFoundError.ts'
@@ -46,6 +48,7 @@ export { UploadedFile } from './http/UploadedFile.ts'
46
48
  export { HttpKernel } from './http/Kernel.ts'
47
49
  export { RouterImpl } from './routing/Router.ts'
48
50
  export { Route } from './routing/Route.ts'
51
+ export type { RouteBinding } from './routing/Route.ts'
49
52
  export { RouteMatched } from './routing/events.ts'
50
53
  export { Pipeline } from './middleware/Pipeline.ts'
51
54
  export { CorsMiddleware } from './middleware/Cors.ts'
@@ -57,6 +60,8 @@ export { RateLimiter, MemoryStore } from './rateLimit/RateLimiter.ts'
57
60
  export type { RateLimitConfig, RateLimitStore, LimiterResolver } from './rateLimit/RateLimiter.ts'
58
61
  export { ThrottleRequests, getDefaultRateLimiter, setDefaultRateLimiter } from './rateLimit/ThrottleRequests.ts'
59
62
  export { SecureHeaders } from './middleware/SecureHeaders.ts'
63
+ export { TimeoutMiddleware } from './middleware/TimeoutMiddleware.ts'
64
+ export { RouteModelBinding } from './middleware/RouteModelBinding.ts'
60
65
  export { Enum } from './support/Enum.ts'
61
66
  export { WebSocketKernel } from './websocket/WebSocketKernel.ts'
62
67
  export { DefaultExceptionHandler } from './exceptions/Handler.ts'
@@ -64,6 +69,9 @@ export { CoreServiceProvider } from './providers/CoreServiceProvider.ts'
64
69
  export { Discoverer } from './discovery/Discoverer.ts'
65
70
  export type { DiscoveryManifest } from './discovery/Discoverer.ts'
66
71
 
72
+ // ── URL Signing ──────────────────────────────────────────────────────────────
73
+ export { UrlSigner } from './url/UrlSigner.ts'
74
+
67
75
  // ── Encryption ────────────────────────────────────────────────────────────────
68
76
  export { AesEncrypter } from './encryption/Encrypter.ts'
69
77
 
@@ -103,4 +111,5 @@ export { hash, hashCheck } from './helpers/hash.ts'
103
111
  export { cache } from './helpers/cache.ts'
104
112
  export { session } from './helpers/session.ts'
105
113
  export { dd, dump } from './helpers/dd.ts'
114
+ export { signedUrl, hasValidSignature } from './helpers/signedUrl.ts'
106
115
  export { base_path, app_path, config_path, database_path, storage_path, public_path, resource_path } from './helpers/paths.ts'
@@ -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: configRepo?.get('cors.origin', defaultOrigin) ?? defaultOrigin,
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: configRepo?.get('cors.credentials', defaultCredentials) ?? defaultCredentials,
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
- if (this.config.credentials) {
80
- // CORS spec forbids Access-Control-Allow-Origin: * with credentials.
81
- // Reflect the request's Origin header instead; if absent, omit CORS headers entirely.
82
- if (!requestOrigin) return
83
- headers.set('Access-Control-Allow-Origin', requestOrigin)
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)
@@ -47,11 +47,13 @@ export class EncryptCookies implements Middleware {
47
47
  const decoded = decodeURIComponent(value)
48
48
  decrypted[name] = await this.encrypter.decrypt(decoded)
49
49
  } catch {
50
- // Can't decrypt — expired key, tampered, or wrong format
50
+ // Can't decrypt — expired key, tampered, or wrong format.
51
+ // Discard the value instead of passing through the encrypted blob,
52
+ // which could cause unexpected behavior downstream.
51
53
  if (process.env.APP_DEBUG === 'true') {
52
- console.warn(`[Mantiq] Failed to decrypt cookie "${name}" — using raw value`)
54
+ console.warn(`[Mantiq] Failed to decrypt cookie "${name}" — discarding value`)
53
55
  }
54
- decrypted[name] = value
56
+ decrypted[name] = ''
55
57
  }
56
58
  }
57
59
 
@@ -86,10 +88,18 @@ export class EncryptCookies implements Middleware {
86
88
  try {
87
89
  const encrypted = await this.encrypter.encrypt(value)
88
90
  const newSetCookie = `${encodeURIComponent(name)}=${encodeURIComponent(encrypted)}; ${parts.join('; ')}`
91
+
92
+ if (newSetCookie.length > 4096) {
93
+ console.warn(
94
+ `[Mantiq] Cookie "${name}" exceeds 4KB (${newSetCookie.length} bytes) — browsers may silently ignore it.`,
95
+ )
96
+ }
97
+
89
98
  headers.append('Set-Cookie', newSetCookie)
90
99
  } catch {
91
- // If encryption fails, send unencrypted (shouldn't happen with valid key)
92
- headers.append('Set-Cookie', setCookie)
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.`)
93
103
  }
94
104
  }
95
105
 
@@ -0,0 +1,43 @@
1
+ import type { Middleware, NextFunction } from '../contracts/Middleware.ts'
2
+ import type { MantiqRequest } from '../contracts/Request.ts'
3
+
4
+ /**
5
+ * Middleware that resolves route parameters to model instances.
6
+ *
7
+ * Register bindings before adding to the middleware stack:
8
+ *
9
+ * @example
10
+ * const binding = new RouteModelBinding()
11
+ * binding.bind('user', User) // looks up User.where('id', value).first()
12
+ * binding.bind('post', Post, 'slug') // looks up Post.where('slug', value).first()
13
+ *
14
+ * kernel.registerMiddleware('bindings', RouteModelBinding)
15
+ */
16
+ export class RouteModelBinding implements Middleware {
17
+ private bindings = new Map<string, { model: any; key: string }>()
18
+
19
+ bind(param: string, model: any, key = 'id'): void {
20
+ this.bindings.set(param, { model, key })
21
+ }
22
+
23
+ async handle(request: MantiqRequest, next: NextFunction): Promise<Response> {
24
+ const params = request.params()
25
+
26
+ for (const [param, { model, key }] of this.bindings) {
27
+ const value = params[param]
28
+ if (value === undefined) continue
29
+
30
+ const instance = await model.where(key, value).first()
31
+ if (!instance) {
32
+ return new Response(
33
+ JSON.stringify({ error: `${param} not found.` }),
34
+ { status: 404, headers: { 'Content-Type': 'application/json' } },
35
+ )
36
+ }
37
+
38
+ request.setRouteParam(param, instance)
39
+ }
40
+
41
+ return next()
42
+ }
43
+ }
@@ -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
  }
@@ -0,0 +1,47 @@
1
+ import type { Middleware, NextFunction } from '../contracts/Middleware.ts'
2
+ import type { MantiqRequest } from '../contracts/Request.ts'
3
+
4
+ /**
5
+ * Request timeout middleware.
6
+ *
7
+ * Aborts requests that exceed a configurable time limit, returning a 408
8
+ * status. Default timeout is 30 seconds.
9
+ *
10
+ * Usage with route alias:
11
+ * route.get('/heavy', handler).middleware('timeout') // 30s default
12
+ * route.get('/heavy', handler).middleware('timeout:60') // 60s
13
+ */
14
+ export class TimeoutMiddleware implements Middleware {
15
+ private timeout = 30_000 // default 30s
16
+
17
+ setParameters(params: string[]): void {
18
+ if (params[0]) this.timeout = Number(params[0]) * 1000
19
+ }
20
+
21
+ async handle(request: MantiqRequest, next: NextFunction): Promise<Response> {
22
+ const controller = new AbortController()
23
+ const timer = setTimeout(() => controller.abort(), this.timeout)
24
+
25
+ try {
26
+ const response = await Promise.race([
27
+ next(),
28
+ new Promise<never>((_, reject) => {
29
+ controller.signal.addEventListener('abort', () =>
30
+ reject(new Error(`Request timed out after ${this.timeout}ms`)),
31
+ )
32
+ }),
33
+ ])
34
+ clearTimeout(timer)
35
+ return response
36
+ } catch (err: any) {
37
+ clearTimeout(timer)
38
+ if (err.message?.includes('timed out')) {
39
+ return new Response(JSON.stringify({ error: 'Request Timeout' }), {
40
+ status: 408,
41
+ headers: { 'Content-Type': 'application/json' },
42
+ })
43
+ }
44
+ throw err
45
+ }
46
+ }
47
+ }
@@ -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
- let result = 0
125
- for (let i = 0; i < bufA.length; i++) {
126
- result |= bufA[i]! ^ bufB[i]!
127
- }
128
- return result === 0
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
  }
@@ -11,6 +11,7 @@ import { EncryptCookies } from '../middleware/EncryptCookies.ts'
11
11
  import { VerifyCsrfToken } from '../middleware/VerifyCsrfToken.ts'
12
12
  import { ThrottleRequests } from '../rateLimit/ThrottleRequests.ts'
13
13
  import { SecureHeaders } from '../middleware/SecureHeaders.ts'
14
+ import { TimeoutMiddleware } from '../middleware/TimeoutMiddleware.ts'
14
15
  import { ROUTER } from '../helpers/route.ts'
15
16
  import { ENCRYPTER } from '../helpers/encrypt.ts'
16
17
  import { AesEncrypter } from '../encryption/Encrypter.ts'
@@ -82,6 +83,7 @@ export class CoreServiceProvider extends ServiceProvider {
82
83
  // Rate limiting — zero-config, uses shared in-memory store
83
84
  this.app.singleton(ThrottleRequests, () => new ThrottleRequests())
84
85
  this.app.singleton(SecureHeaders, () => new SecureHeaders())
86
+ this.app.bind(TimeoutMiddleware, () => new TimeoutMiddleware())
85
87
 
86
88
  // HTTP kernel — singleton, depends on Router + ExceptionHandler + WsKernel
87
89
  this.app.singleton(HttpKernel, (c) => {
@@ -113,6 +115,7 @@ export class CoreServiceProvider extends ServiceProvider {
113
115
  kernel.registerMiddleware('session', StartSession)
114
116
  kernel.registerMiddleware('csrf', VerifyCsrfToken)
115
117
  kernel.registerMiddleware('secure-headers', SecureHeaders)
118
+ kernel.registerMiddleware('timeout', TimeoutMiddleware)
116
119
 
117
120
  // Register middleware groups from config
118
121
  const configRepo = this.app.make(ConfigRepository)
@@ -125,6 +128,29 @@ export class CoreServiceProvider extends ServiceProvider {
125
128
  kernel.registerMiddlewareGroup(name, middleware)
126
129
  }
127
130
 
131
+ // ── Boot-time convention validation ────────────────────────────────────
132
+ // Validate that all aliases referenced by middleware groups are registered
133
+ for (const [group, aliases] of Object.entries(middlewareGroups)) {
134
+ for (const alias of aliases) {
135
+ const name = alias.split(':')[0]!
136
+ if (!kernel.hasMiddleware(name)) {
137
+ throw new Error(
138
+ `Middleware group '${group}' references unknown alias '${name}'.\n` +
139
+ `Registered aliases: ${kernel.getRegisteredAliases().join(', ')}`,
140
+ )
141
+ }
142
+ }
143
+ }
144
+
145
+ // Validate APP_KEY when encrypt.cookies middleware is active
146
+ const needsKey = Object.values(middlewareGroups).flat().includes('encrypt.cookies')
147
+ if (needsKey && !appKey) {
148
+ throw new Error(
149
+ 'APP_KEY is required when encrypt.cookies middleware is active.\n' +
150
+ 'Generate one with: bun mantiq key:generate',
151
+ )
152
+ }
153
+
128
154
  // Legacy: if app.middleware is set, apply as global middleware (backward compat)
129
155
  const globalMiddleware = configRepo.get('app.middleware', []) as string[]
130
156
  if (globalMiddleware.length > 0) {
@@ -1,9 +1,15 @@
1
1
  import type { HttpMethod, RouteAction, RouterRoute } from '../contracts/Router.ts'
2
2
 
3
+ export interface RouteBinding {
4
+ model: any
5
+ key: string
6
+ }
7
+
3
8
  export class Route implements RouterRoute {
4
9
  public routeName?: string
5
10
  public middlewareList: string[] = []
6
11
  public wheres: Record<string, RegExp> = {}
12
+ public bindings = new Map<string, RouteBinding>()
7
13
 
8
14
  constructor(
9
15
  public readonly methods: HttpMethod[],
@@ -37,4 +43,9 @@ export class Route implements RouterRoute {
37
43
  whereUuid(param: string): this {
38
44
  return this.where(param, /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i)
39
45
  }
46
+
47
+ bind(param: string, model: any, key = 'id'): this {
48
+ this.bindings.set(param, { model, key })
49
+ return this
50
+ }
40
51
  }
@@ -160,12 +160,43 @@ export class RouterImpl implements RouterContract {
160
160
  const result = RouteMatcher.match(route, pathname)
161
161
  if (result) {
162
162
  RouterImpl._dispatcher?.emit(new RouteMatched(route.routeName, route.action, request))
163
- return {
163
+
164
+ // Merge route-level bindings with router-level bindings (route-level takes precedence)
165
+ const bindings = new Map<string, { model: any; key: string }>()
166
+
167
+ // Add router-level model bindings (model class → where('id', value).first())
168
+ for (const [param, ModelClass] of this.modelBindings) {
169
+ if (result.params[param] !== undefined) {
170
+ bindings.set(param, { model: ModelClass, key: 'id' })
171
+ }
172
+ }
173
+
174
+ // Add router-level custom bindings (resolver function)
175
+ for (const [param, resolver] of this.customBindings) {
176
+ if (result.params[param] !== undefined) {
177
+ bindings.set(param, { model: resolver, key: '__custom__' })
178
+ }
179
+ }
180
+
181
+ // Route-level bindings override router-level
182
+ for (const [param, binding] of route.bindings) {
183
+ if (result.params[param] !== undefined) {
184
+ bindings.set(param, binding)
185
+ }
186
+ }
187
+
188
+ const match: RouteMatch = {
164
189
  action: route.action,
165
190
  params: result.params,
166
191
  middleware: route.middlewareList,
167
192
  routeName: route.routeName,
168
193
  }
194
+
195
+ if (bindings.size > 0) {
196
+ match.bindings = bindings
197
+ }
198
+
199
+ return match
169
200
  }
170
201
  }
171
202
 
@@ -9,7 +9,8 @@ const SESSION_DEFAULTS: SessionConfig = {
9
9
  lifetime: 120,
10
10
  cookie: 'mantiq_session',
11
11
  path: '/',
12
- secure: false,
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
  }
@@ -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
- const bytes = new Uint8Array(20)
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++) {