@mantiq/core 0.2.1 → 0.3.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mantiq/core",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "Service container, router, middleware, HTTP kernel, config, and exception handler",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -54,10 +54,7 @@ export class DefaultExceptionHandler implements ExceptionHandler {
54
54
  return MantiqResponse.redirect((err as any).redirectTo)
55
55
  }
56
56
 
57
- if (debug) {
58
- return MantiqResponse.html(renderDevErrorPage(request, err), err.statusCode)
59
- }
60
-
57
+ // API routes always get JSON — even in debug mode
61
58
  if (request.expectsJson()) {
62
59
  const body: Record<string, any> = {
63
60
  error: { message: err.message, status: err.statusCode },
@@ -65,9 +62,16 @@ export class DefaultExceptionHandler implements ExceptionHandler {
65
62
  if (err instanceof ValidationError) {
66
63
  body['error']['errors'] = err.errors
67
64
  }
65
+ if (debug && err.stack) {
66
+ body['error']['trace'] = err.stack.split('\n').map((l: string) => l.trim())
67
+ }
68
68
  return MantiqResponse.json(body, err.statusCode, err.headers)
69
69
  }
70
70
 
71
+ if (debug) {
72
+ return MantiqResponse.html(renderDevErrorPage(request, err), err.statusCode)
73
+ }
74
+
71
75
  return MantiqResponse.html(
72
76
  this.genericHtmlPage(err.statusCode, err.message),
73
77
  err.statusCode,
@@ -79,15 +83,18 @@ export class DefaultExceptionHandler implements ExceptionHandler {
79
83
  err: Error,
80
84
  debug: boolean,
81
85
  ): Response {
82
- if (debug) {
83
- return MantiqResponse.html(renderDevErrorPage(request, err), 500)
86
+ // API routes always get JSON — even in debug mode
87
+ if (request.expectsJson()) {
88
+ const body: Record<string, any> = { error: { message: 'Internal Server Error', status: 500 } }
89
+ if (debug && err.stack) {
90
+ body['error']['message'] = err.message
91
+ body['error']['trace'] = err.stack.split('\n').map((l: string) => l.trim())
92
+ }
93
+ return MantiqResponse.json(body, 500)
84
94
  }
85
95
 
86
- if (request.expectsJson()) {
87
- return MantiqResponse.json(
88
- { error: { message: 'Internal Server Error', status: 500 } },
89
- 500,
90
- )
96
+ if (debug) {
97
+ return MantiqResponse.html(renderDevErrorPage(request, err), 500)
91
98
  }
92
99
 
93
100
  return MantiqResponse.html(this.genericHtmlPage(500, 'Internal Server Error'), 500)
@@ -133,6 +133,8 @@ export class MantiqRequest implements MantiqRequestContract {
133
133
  }
134
134
 
135
135
  expectsJson(): boolean {
136
+ // Routes under /api/ always expect JSON responses
137
+ if (this.path().startsWith('/api/') || this.path() === '/api') return true
136
138
  const accept = this.header('accept') ?? ''
137
139
  return accept.includes('application/json') || accept.includes('text/json')
138
140
  }
package/src/index.ts CHANGED
@@ -53,6 +53,9 @@ export { TrimStringsMiddleware } from './middleware/TrimStrings.ts'
53
53
  export { StartSession } from './middleware/StartSession.ts'
54
54
  export { EncryptCookies } from './middleware/EncryptCookies.ts'
55
55
  export { VerifyCsrfToken } from './middleware/VerifyCsrfToken.ts'
56
+ export { RateLimiter, MemoryStore } from './rateLimit/RateLimiter.ts'
57
+ export type { RateLimitConfig, RateLimitStore, LimiterResolver } from './rateLimit/RateLimiter.ts'
58
+ export { ThrottleRequests } from './rateLimit/ThrottleRequests.ts'
56
59
  export { WebSocketKernel } from './websocket/WebSocketKernel.ts'
57
60
  export { DefaultExceptionHandler } from './exceptions/Handler.ts'
58
61
  export { CoreServiceProvider } from './providers/CoreServiceProvider.ts'
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Rate limiter — tracks request counts per key within time windows.
3
+ *
4
+ * Supports named limiters with custom resolvers, and pluggable stores
5
+ * (memory by default, cache/Redis when available).
6
+ *
7
+ * @example
8
+ * const limiter = new RateLimiter()
9
+ *
10
+ * // Define a named limiter
11
+ * limiter.for('api', (request) => ({
12
+ * key: request.ip(),
13
+ * maxAttempts: 60,
14
+ * decayMinutes: 1,
15
+ * }))
16
+ *
17
+ * // Or with multiple limits
18
+ * limiter.for('uploads', (request) => [
19
+ * { key: request.ip(), maxAttempts: 10, decayMinutes: 1 },
20
+ * { key: request.user()?.id ?? request.ip(), maxAttempts: 100, decayMinutes: 60 },
21
+ * ])
22
+ */
23
+
24
+ export interface RateLimitConfig {
25
+ key: string
26
+ maxAttempts: number
27
+ decayMinutes: number
28
+ responseCallback?: (request: any, headers: Record<string, string>) => Response | void
29
+ }
30
+
31
+ export interface RateLimitStore {
32
+ /** Get current hit count for key. */
33
+ get(key: string): Promise<number>
34
+ /** Increment hit count. Returns new count. */
35
+ increment(key: string, decaySeconds: number): Promise<number>
36
+ /** Get remaining seconds until the key resets. */
37
+ availableIn(key: string): Promise<number>
38
+ /** Reset a key. */
39
+ clear(key: string): Promise<void>
40
+ }
41
+
42
+ export type LimiterResolver = (request: any) => RateLimitConfig | RateLimitConfig[]
43
+
44
+ export class RateLimiter {
45
+ private limiters = new Map<string, LimiterResolver>()
46
+ private store: RateLimitStore
47
+
48
+ constructor(store?: RateLimitStore) {
49
+ this.store = store ?? new MemoryStore()
50
+ }
51
+
52
+ /** Define a named rate limiter. */
53
+ for(name: string, resolver: LimiterResolver): this {
54
+ this.limiters.set(name, resolver)
55
+ return this
56
+ }
57
+
58
+ /** Get a named limiter resolver. */
59
+ limiter(name: string): LimiterResolver | undefined {
60
+ return this.limiters.get(name)
61
+ }
62
+
63
+ /** Check if a key has too many attempts. */
64
+ async tooManyAttempts(key: string, maxAttempts: number): Promise<boolean> {
65
+ const attempts = await this.store.get(key)
66
+ return attempts >= maxAttempts
67
+ }
68
+
69
+ /** Record a hit for a key. Returns the new count. */
70
+ async hit(key: string, decaySeconds: number): Promise<number> {
71
+ return this.store.increment(key, decaySeconds)
72
+ }
73
+
74
+ /** Get current attempt count. */
75
+ async attempts(key: string): Promise<number> {
76
+ return this.store.get(key)
77
+ }
78
+
79
+ /** Get remaining attempts. */
80
+ async remaining(key: string, maxAttempts: number): Promise<number> {
81
+ const current = await this.store.get(key)
82
+ return Math.max(0, maxAttempts - current)
83
+ }
84
+
85
+ /** Get seconds until the rate limit resets. */
86
+ async availableIn(key: string): Promise<number> {
87
+ return this.store.availableIn(key)
88
+ }
89
+
90
+ /** Reset a key's attempt count. */
91
+ async clear(key: string): Promise<void> {
92
+ return this.store.clear(key)
93
+ }
94
+
95
+ /** Set the backing store (memory, cache, redis). */
96
+ setStore(store: RateLimitStore): void {
97
+ this.store = store
98
+ }
99
+ }
100
+
101
+ // ── Memory Store (default) ───────────────────────────────────────────────────
102
+
103
+ interface MemoryEntry {
104
+ count: number
105
+ expiresAt: number // unix ms
106
+ }
107
+
108
+ export class MemoryStore implements RateLimitStore {
109
+ private entries = new Map<string, MemoryEntry>()
110
+
111
+ async get(key: string): Promise<number> {
112
+ const entry = this.entries.get(key)
113
+ if (!entry) return 0
114
+ if (Date.now() > entry.expiresAt) {
115
+ this.entries.delete(key)
116
+ return 0
117
+ }
118
+ return entry.count
119
+ }
120
+
121
+ async increment(key: string, decaySeconds: number): Promise<number> {
122
+ const existing = this.entries.get(key)
123
+ const now = Date.now()
124
+
125
+ if (existing && now <= existing.expiresAt) {
126
+ existing.count++
127
+ return existing.count
128
+ }
129
+
130
+ // New window
131
+ const entry: MemoryEntry = { count: 1, expiresAt: now + decaySeconds * 1000 }
132
+ this.entries.set(key, entry)
133
+ return 1
134
+ }
135
+
136
+ async availableIn(key: string): Promise<number> {
137
+ const entry = this.entries.get(key)
138
+ if (!entry) return 0
139
+ const remaining = Math.ceil((entry.expiresAt - Date.now()) / 1000)
140
+ return Math.max(0, remaining)
141
+ }
142
+
143
+ async clear(key: string): Promise<void> {
144
+ this.entries.delete(key)
145
+ }
146
+ }
@@ -0,0 +1,135 @@
1
+ import type { MantiqRequest } from '../contracts/Request.ts'
2
+ import { HttpError } from '../errors/HttpError.ts'
3
+ import type { RateLimiter, RateLimitConfig } from './RateLimiter.ts'
4
+
5
+ /**
6
+ * Middleware that throttles requests using the RateLimiter.
7
+ *
8
+ * Usage with named limiter:
9
+ * router.get('/api/data', handler).middleware('throttle:api')
10
+ *
11
+ * Usage with inline limits:
12
+ * router.get('/api/data', handler).middleware('throttle:60,1')
13
+ * // 60 requests per 1 minute, keyed by IP
14
+ *
15
+ * Response headers:
16
+ * X-RateLimit-Limit: 60
17
+ * X-RateLimit-Remaining: 45
18
+ * Retry-After: 30 (only when rate limited)
19
+ */
20
+ export class ThrottleRequests {
21
+ private params: string[] = []
22
+
23
+ constructor(private readonly rateLimiter: RateLimiter) {}
24
+
25
+ setParameters(...params: string[]): void {
26
+ this.params = params
27
+ }
28
+
29
+ async handle(request: MantiqRequest, next: () => Promise<Response>): Promise<Response> {
30
+ const configs = this.resolveConfigs(request)
31
+
32
+ // Check all limits before proceeding
33
+ for (const config of configs) {
34
+ const fullKey = `rate_limit:${config.key}`
35
+
36
+ if (await this.rateLimiter.tooManyAttempts(fullKey, config.maxAttempts)) {
37
+ const retryAfter = await this.rateLimiter.availableIn(fullKey)
38
+ return this.buildTooManyResponse(request, config, retryAfter)
39
+ }
40
+ }
41
+
42
+ // Record hits
43
+ for (const config of configs) {
44
+ const fullKey = `rate_limit:${config.key}`
45
+ await this.rateLimiter.hit(fullKey, config.decayMinutes * 60)
46
+ }
47
+
48
+ // Process request
49
+ const response = await next()
50
+
51
+ // Add rate limit headers (use the most restrictive limit)
52
+ return this.addHeaders(response, configs)
53
+ }
54
+
55
+ private resolveConfigs(request: MantiqRequest): RateLimitConfig[] {
56
+ if (this.params.length === 0) {
57
+ // Default: 60 per minute by IP
58
+ return [{ key: request.ip(), maxAttempts: 60, decayMinutes: 1 }]
59
+ }
60
+
61
+ const first = this.params[0]!
62
+
63
+ // Check if it's a named limiter
64
+ const resolver = this.rateLimiter.limiter(first)
65
+ if (resolver) {
66
+ const result = resolver(request)
67
+ return Array.isArray(result) ? result : [result]
68
+ }
69
+
70
+ // Inline: throttle:maxAttempts,decayMinutes
71
+ const maxAttempts = parseInt(first, 10) || 60
72
+ const decayMinutes = parseInt(this.params[1] ?? '1', 10) || 1
73
+
74
+ return [{
75
+ key: request.ip(),
76
+ maxAttempts,
77
+ decayMinutes,
78
+ }]
79
+ }
80
+
81
+ private buildTooManyResponse(
82
+ request: MantiqRequest,
83
+ config: RateLimitConfig,
84
+ retryAfter: number,
85
+ ): Response {
86
+ if (config.responseCallback) {
87
+ const headers: Record<string, string> = {
88
+ 'Retry-After': String(retryAfter),
89
+ 'X-RateLimit-Limit': String(config.maxAttempts),
90
+ 'X-RateLimit-Remaining': '0',
91
+ }
92
+ const custom = config.responseCallback(request, headers)
93
+ if (custom) return custom
94
+ }
95
+
96
+ const body = request.expectsJson()
97
+ ? JSON.stringify({ message: 'Too Many Requests', retry_after: retryAfter })
98
+ : 'Too Many Requests'
99
+
100
+ return new Response(body, {
101
+ status: 429,
102
+ headers: {
103
+ 'Content-Type': request.expectsJson() ? 'application/json' : 'text/plain',
104
+ 'Retry-After': String(retryAfter),
105
+ 'X-RateLimit-Limit': String(config.maxAttempts),
106
+ 'X-RateLimit-Remaining': '0',
107
+ },
108
+ })
109
+ }
110
+
111
+ private async addHeaders(response: Response, configs: RateLimitConfig[]): Promise<Response> {
112
+ // Use the most restrictive limit for headers
113
+ let minRemaining = Infinity
114
+ let limit = 0
115
+
116
+ for (const config of configs) {
117
+ const fullKey = `rate_limit:${config.key}`
118
+ const remaining = await this.rateLimiter.remaining(fullKey, config.maxAttempts)
119
+ if (remaining < minRemaining) {
120
+ minRemaining = remaining
121
+ limit = config.maxAttempts
122
+ }
123
+ }
124
+
125
+ const headers = new Headers(response.headers)
126
+ headers.set('X-RateLimit-Limit', String(limit))
127
+ headers.set('X-RateLimit-Remaining', String(minRemaining))
128
+
129
+ return new Response(response.body, {
130
+ status: response.status,
131
+ statusText: response.statusText,
132
+ headers,
133
+ })
134
+ }
135
+ }