@mantiq/core 0.5.21 → 0.5.23

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 (40) hide show
  1. package/package.json +1 -1
  2. package/src/application/Application.ts +65 -7
  3. package/src/cache/FileCacheStore.ts +42 -3
  4. package/src/cache/MemoryCacheStore.ts +14 -2
  5. package/src/contracts/Request.ts +10 -2
  6. package/src/contracts/Router.ts +2 -0
  7. package/src/discovery/Discoverer.ts +1 -3
  8. package/src/encryption/errors.ts +5 -2
  9. package/src/errors/ConfigKeyNotFoundError.ts +6 -1
  10. package/src/errors/ContainerResolutionError.ts +3 -0
  11. package/src/errors/ErrorCodes.ts +27 -0
  12. package/src/errors/ForbiddenError.ts +2 -1
  13. package/src/errors/HttpError.ts +4 -1
  14. package/src/errors/MantiqError.ts +12 -1
  15. package/src/errors/NotFoundError.ts +2 -1
  16. package/src/errors/TokenMismatchError.ts +2 -1
  17. package/src/errors/TooManyRequestsError.ts +2 -1
  18. package/src/errors/UnauthorizedError.ts +2 -1
  19. package/src/errors/ValidationError.ts +2 -1
  20. package/src/exceptions/DevErrorPage.ts +27 -5
  21. package/src/exceptions/Handler.ts +1 -0
  22. package/src/helpers/signedUrl.ts +26 -0
  23. package/src/helpers/url.ts +31 -0
  24. package/src/http/Kernel.ts +91 -2
  25. package/src/http/Request.ts +60 -11
  26. package/src/http/Response.ts +54 -1
  27. package/src/http/UploadedFile.ts +24 -1
  28. package/src/index.ts +11 -0
  29. package/src/middleware/Cors.ts +9 -1
  30. package/src/middleware/EncryptCookies.ts +14 -2
  31. package/src/middleware/RouteModelBinding.ts +43 -0
  32. package/src/middleware/SecureHeaders.ts +72 -0
  33. package/src/middleware/TimeoutMiddleware.ts +47 -0
  34. package/src/providers/CoreServiceProvider.ts +33 -0
  35. package/src/routing/Route.ts +11 -0
  36. package/src/routing/Router.ts +32 -1
  37. package/src/session/Store.ts +2 -1
  38. package/src/support/Enum.ts +96 -0
  39. package/src/url/UrlSigner.ts +131 -0
  40. package/src/websocket/WebSocketKernel.ts +45 -0
@@ -10,6 +10,8 @@ import { StartSession } from '../middleware/StartSession.ts'
10
10
  import { EncryptCookies } from '../middleware/EncryptCookies.ts'
11
11
  import { VerifyCsrfToken } from '../middleware/VerifyCsrfToken.ts'
12
12
  import { ThrottleRequests } from '../rateLimit/ThrottleRequests.ts'
13
+ import { SecureHeaders } from '../middleware/SecureHeaders.ts'
14
+ import { TimeoutMiddleware } from '../middleware/TimeoutMiddleware.ts'
13
15
  import { ROUTER } from '../helpers/route.ts'
14
16
  import { ENCRYPTER } from '../helpers/encrypt.ts'
15
17
  import { AesEncrypter } from '../encryption/Encrypter.ts'
@@ -80,6 +82,8 @@ export class CoreServiceProvider extends ServiceProvider {
80
82
 
81
83
  // Rate limiting — zero-config, uses shared in-memory store
82
84
  this.app.singleton(ThrottleRequests, () => new ThrottleRequests())
85
+ this.app.singleton(SecureHeaders, () => new SecureHeaders())
86
+ this.app.bind(TimeoutMiddleware, () => new TimeoutMiddleware())
83
87
 
84
88
  // HTTP kernel — singleton, depends on Router + ExceptionHandler + WsKernel
85
89
  this.app.singleton(HttpKernel, (c) => {
@@ -96,6 +100,10 @@ export class CoreServiceProvider extends ServiceProvider {
96
100
  if (appKey) {
97
101
  const encrypter = await AesEncrypter.fromAppKey(appKey)
98
102
  this.app.instance(ENCRYPTER, encrypter)
103
+
104
+ // Give WebSocketKernel the encrypter so it can decrypt cookies on upgrade
105
+ const wsKernel = this.app.make(WebSocketKernel)
106
+ wsKernel.setEncrypter(encrypter)
99
107
  }
100
108
 
101
109
  // ── Auto-register middleware aliases on HttpKernel ─────────────────────
@@ -106,6 +114,8 @@ export class CoreServiceProvider extends ServiceProvider {
106
114
  kernel.registerMiddleware('encrypt.cookies', EncryptCookies)
107
115
  kernel.registerMiddleware('session', StartSession)
108
116
  kernel.registerMiddleware('csrf', VerifyCsrfToken)
117
+ kernel.registerMiddleware('secure-headers', SecureHeaders)
118
+ kernel.registerMiddleware('timeout', TimeoutMiddleware)
109
119
 
110
120
  // Register middleware groups from config
111
121
  const configRepo = this.app.make(ConfigRepository)
@@ -118,6 +128,29 @@ export class CoreServiceProvider extends ServiceProvider {
118
128
  kernel.registerMiddlewareGroup(name, middleware)
119
129
  }
120
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
+
121
154
  // Legacy: if app.middleware is set, apply as global middleware (backward compat)
122
155
  const globalMiddleware = configRepo.get('app.middleware', []) as string[]
123
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
 
@@ -40,7 +40,8 @@ export class SessionStore {
40
40
  */
41
41
  async save(): Promise<void> {
42
42
  await this.handler.write(this.id, JSON.stringify(this.attributes))
43
- this.started = false
43
+ // Keep started=true so the session remains writable after save.
44
+ // Middleware may still need to write to the session after saving.
44
45
  }
45
46
 
46
47
  // ── Getters & setters ───────────────────────────────────────────────────
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Base Enum class — Laravel-style backed enums for TypeScript.
3
+ *
4
+ * @example
5
+ * class UserStatus extends Enum {
6
+ * static Active = new UserStatus('active')
7
+ * static Inactive = new UserStatus('inactive')
8
+ * static Banned = new UserStatus('banned')
9
+ * }
10
+ *
11
+ * UserStatus.from('active') // → UserStatus.Active
12
+ * UserStatus.values() // → ['active', 'inactive', 'banned']
13
+ * UserStatus.cases() // → [Active, Inactive, Banned]
14
+ * UserStatus.Active.value // → 'active'
15
+ * UserStatus.Active.label // → 'Active'
16
+ * UserStatus.Active.is(status) // → boolean
17
+ */
18
+ export class Enum {
19
+ readonly value: string | number
20
+
21
+ constructor(value: string | number) {
22
+ this.value = value
23
+ }
24
+
25
+ /** Human-readable label — derived from the static property name. */
26
+ get label(): string {
27
+ const ctor = this.constructor as typeof Enum
28
+ for (const [key, val] of Object.entries(ctor)) {
29
+ if (val === this) {
30
+ // Convert PascalCase/camelCase to spaced: 'InProgress' → 'In Progress'
31
+ return key.replace(/([a-z])([A-Z])/g, '$1 $2')
32
+ }
33
+ }
34
+ return String(this.value)
35
+ }
36
+
37
+ /** Check if this enum equals another value (enum instance, string, or number). */
38
+ is(other: Enum | string | number): boolean {
39
+ if (other instanceof Enum) return this.value === other.value
40
+ return this.value === other
41
+ }
42
+
43
+ /** Check if this enum does NOT equal another value. */
44
+ isNot(other: Enum | string | number): boolean {
45
+ return !this.is(other)
46
+ }
47
+
48
+ /** String representation — returns the raw value. */
49
+ toString(): string {
50
+ return String(this.value)
51
+ }
52
+
53
+ /** JSON serialization — returns the raw value. */
54
+ toJSON(): string | number {
55
+ return this.value
56
+ }
57
+
58
+ // ── Static methods (called on the subclass) ────────────────────────────
59
+
60
+ /** Get all enum instances. */
61
+ static cases(): Enum[] {
62
+ return Object.values(this).filter((v) => v instanceof Enum)
63
+ }
64
+
65
+ /** Get all raw values. */
66
+ static values(): (string | number)[] {
67
+ return this.cases().map((c) => c.value)
68
+ }
69
+
70
+ /** Get all labels. */
71
+ static labels(): string[] {
72
+ return this.cases().map((c) => c.label)
73
+ }
74
+
75
+ /** Get an enum instance from a raw value. Throws if not found. */
76
+ static from(value: string | number): Enum {
77
+ const found = this.tryFrom(value)
78
+ if (!found) throw new Error(`"${value}" is not a valid ${this.name} value. Valid: ${this.values().join(', ')}`)
79
+ return found
80
+ }
81
+
82
+ /** Get an enum instance from a raw value. Returns null if not found. */
83
+ static tryFrom(value: string | number): Enum | null {
84
+ return this.cases().find((c) => c.value === value) ?? null
85
+ }
86
+
87
+ /** Check if a value is valid for this enum. */
88
+ static has(value: string | number): boolean {
89
+ return this.values().includes(value)
90
+ }
91
+
92
+ /** Get a map of value → label for select dropdowns, etc. */
93
+ static options(): Array<{ value: string | number; label: string }> {
94
+ return this.cases().map((c) => ({ value: c.value, label: c.label }))
95
+ }
96
+ }
@@ -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
+ }
@@ -1,5 +1,7 @@
1
1
  import type { WebSocketHandler } from './WebSocketContext.ts'
2
+ import type { AesEncrypter } from '../encryption/Encrypter.ts'
2
3
  import { MantiqRequest } from '../http/Request.ts'
4
+ import { parseCookies } from '../http/Cookie.ts'
3
5
 
4
6
  /**
5
7
  * Handles WebSocket upgrade detection and lifecycle delegation.
@@ -9,6 +11,7 @@ import { MantiqRequest } from '../http/Request.ts'
9
11
  */
10
12
  export class WebSocketKernel {
11
13
  private handler: WebSocketHandler | null = null
14
+ private encrypter: AesEncrypter | null = null
12
15
 
13
16
  /**
14
17
  * Called by @mantiq/realtime to register its WebSocket handler.
@@ -17,6 +20,14 @@ export class WebSocketKernel {
17
20
  this.handler = handler
18
21
  }
19
22
 
23
+ /**
24
+ * Inject the encrypter so WebSocket upgrades can decrypt cookies.
25
+ * Called during CoreServiceProvider boot.
26
+ */
27
+ setEncrypter(encrypter: AesEncrypter): void {
28
+ this.encrypter = encrypter
29
+ }
30
+
20
31
  /**
21
32
  * Called by HttpKernel when an upgrade request is detected.
22
33
  */
@@ -29,6 +40,13 @@ export class WebSocketKernel {
29
40
  }
30
41
 
31
42
  const mantiqRequest = MantiqRequest.fromBun(request)
43
+
44
+ // Decrypt cookies — WebSocket upgrades bypass the middleware pipeline,
45
+ // so EncryptCookies never runs. Manually decrypt here.
46
+ if (this.encrypter) {
47
+ await this.decryptCookies(mantiqRequest)
48
+ }
49
+
32
50
  const context = await this.handler.onUpgrade(mantiqRequest)
33
51
 
34
52
  if (!context) {
@@ -57,4 +75,31 @@ export class WebSocketKernel {
57
75
  drain: (ws: any) => h?.drain(ws),
58
76
  }
59
77
  }
78
+
79
+ // ── Private ───────────────────────────────────────────────────────────────
80
+
81
+ private async decryptCookies(request: MantiqRequest): Promise<void> {
82
+ const cookieHeader = request.header('cookie')
83
+ if (!cookieHeader) return
84
+
85
+ const cookies = parseCookies(cookieHeader)
86
+ const decrypted: Record<string, string> = {}
87
+ const except = ['XSRF-TOKEN']
88
+
89
+ for (const [name, value] of Object.entries(cookies)) {
90
+ if (except.includes(name)) {
91
+ decrypted[name] = value
92
+ continue
93
+ }
94
+ try {
95
+ decrypted[name] = await this.encrypter!.decrypt(value)
96
+ } catch {
97
+ // Can't decrypt — expired key, tampered, or wrong format.
98
+ // Don't pass through the encrypted blob; discard it.
99
+ decrypted[name] = ''
100
+ }
101
+ }
102
+
103
+ request.setCookies(decrypted)
104
+ }
60
105
  }