@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.
- package/package.json +1 -1
- package/src/application/Application.ts +65 -7
- package/src/cache/FileCacheStore.ts +42 -3
- package/src/cache/MemoryCacheStore.ts +14 -2
- package/src/contracts/Request.ts +10 -2
- package/src/contracts/Router.ts +2 -0
- package/src/discovery/Discoverer.ts +1 -3
- 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/DevErrorPage.ts +27 -5
- package/src/exceptions/Handler.ts +1 -0
- package/src/helpers/signedUrl.ts +26 -0
- package/src/helpers/url.ts +31 -0
- package/src/http/Kernel.ts +91 -2
- package/src/http/Request.ts +60 -11
- package/src/http/Response.ts +54 -1
- package/src/http/UploadedFile.ts +24 -1
- package/src/index.ts +11 -0
- package/src/middleware/Cors.ts +9 -1
- package/src/middleware/EncryptCookies.ts +14 -2
- package/src/middleware/RouteModelBinding.ts +43 -0
- package/src/middleware/SecureHeaders.ts +72 -0
- package/src/middleware/TimeoutMiddleware.ts +47 -0
- package/src/providers/CoreServiceProvider.ts +33 -0
- package/src/routing/Route.ts +11 -0
- package/src/routing/Router.ts +32 -1
- package/src/session/Store.ts +2 -1
- package/src/support/Enum.ts +96 -0
- package/src/url/UrlSigner.ts +131 -0
- 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) {
|
package/src/routing/Route.ts
CHANGED
|
@@ -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
|
}
|
package/src/routing/Router.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
package/src/session/Store.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|