@mantiq/core 0.5.22 → 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 +53 -0
- package/src/cache/FileCacheStore.ts +38 -6
- package/src/contracts/Request.ts +3 -2
- package/src/contracts/Router.ts +2 -0
- 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/helpers/signedUrl.ts +26 -0
- package/src/helpers/url.ts +31 -0
- package/src/http/Kernel.ts +56 -0
- package/src/http/Request.ts +9 -5
- package/src/index.ts +9 -0
- package/src/middleware/EncryptCookies.ts +12 -3
- package/src/middleware/RouteModelBinding.ts +43 -0
- package/src/middleware/TimeoutMiddleware.ts +47 -0
- package/src/providers/CoreServiceProvider.ts +26 -0
- package/src/routing/Route.ts +11 -0
- package/src/routing/Router.ts +32 -1
- package/src/url/UrlSigner.ts +131 -0
- package/src/websocket/WebSocketKernel.ts +3 -1
package/package.json
CHANGED
|
@@ -210,11 +210,64 @@ export class Application extends ContainerImpl {
|
|
|
210
210
|
coreProviders: Constructor<ServiceProvider>[] = [],
|
|
211
211
|
userProviders: Constructor<ServiceProvider>[] = [],
|
|
212
212
|
): Promise<void> {
|
|
213
|
+
this.validateEnvironment()
|
|
213
214
|
const packageProviders = await this.discoverPackageProviders()
|
|
214
215
|
await this.registerProviders([...coreProviders, ...packageProviders, ...userProviders])
|
|
215
216
|
await this.bootProviders()
|
|
216
217
|
}
|
|
217
218
|
|
|
219
|
+
/**
|
|
220
|
+
* Validate environment variables on startup.
|
|
221
|
+
* Called at the beginning of bootstrap() to catch misconfigurations early.
|
|
222
|
+
*
|
|
223
|
+
* Checks:
|
|
224
|
+
* - APP_ENV must be set
|
|
225
|
+
* - APP_KEY format (if set): must start with 'base64:' and decode to exactly 32 bytes
|
|
226
|
+
* - Warns if APP_DEBUG=true in production
|
|
227
|
+
*/
|
|
228
|
+
validateEnvironment(): void {
|
|
229
|
+
// APP_ENV must be set
|
|
230
|
+
if (!process.env['APP_ENV']) {
|
|
231
|
+
throw new Error(
|
|
232
|
+
'APP_ENV is not set. Set it in your .env file (e.g., APP_ENV=local).',
|
|
233
|
+
)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Validate APP_KEY format when present
|
|
237
|
+
const appKey = process.env['APP_KEY']
|
|
238
|
+
if (appKey) {
|
|
239
|
+
if (!appKey.startsWith('base64:')) {
|
|
240
|
+
throw new Error(
|
|
241
|
+
'APP_KEY must start with "base64:". Generate one with: bun mantiq key:generate',
|
|
242
|
+
)
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
try {
|
|
246
|
+
const decoded = Buffer.from(appKey.slice(7), 'base64')
|
|
247
|
+
if (decoded.length !== 32) {
|
|
248
|
+
throw new Error(
|
|
249
|
+
`APP_KEY must decode to exactly 32 bytes (got ${decoded.length}). ` +
|
|
250
|
+
'Generate one with: bun mantiq key:generate',
|
|
251
|
+
)
|
|
252
|
+
}
|
|
253
|
+
} catch (e) {
|
|
254
|
+
if (e instanceof Error && e.message.startsWith('APP_KEY must')) {
|
|
255
|
+
throw e
|
|
256
|
+
}
|
|
257
|
+
throw new Error(
|
|
258
|
+
'APP_KEY contains invalid base64 data. Generate one with: bun mantiq key:generate',
|
|
259
|
+
)
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Warn on APP_DEBUG=true in production
|
|
264
|
+
if (process.env['APP_ENV'] === 'production' && process.env['APP_DEBUG'] === 'true') {
|
|
265
|
+
console.warn(
|
|
266
|
+
'[Mantiq] WARNING: APP_DEBUG=true in production. This may expose sensitive data.',
|
|
267
|
+
)
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
218
271
|
// ── Provider lifecycle ────────────────────────────────────────────────────
|
|
219
272
|
|
|
220
273
|
/**
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { CacheStore } from '../contracts/Cache.ts'
|
|
2
2
|
import { join } from 'node:path'
|
|
3
|
-
import { mkdir, rm, readdir } from 'node:fs/promises'
|
|
3
|
+
import { mkdir, rm, readdir, rename } from 'node:fs/promises'
|
|
4
|
+
import { randomBytes } from 'node:crypto'
|
|
4
5
|
|
|
5
6
|
interface FileCachePayload {
|
|
6
7
|
value: unknown
|
|
@@ -94,13 +95,44 @@ export class FileCacheStore implements CacheStore {
|
|
|
94
95
|
/**
|
|
95
96
|
* Store an item in the cache if the key does not already exist.
|
|
96
97
|
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
* add semantics, use the Redis or Memcached cache driver.
|
|
98
|
+
* Uses write-to-temp + rename to avoid TOCTOU race conditions.
|
|
99
|
+
* If the key already exists (and is not expired), returns false.
|
|
100
100
|
*/
|
|
101
101
|
async add(key: string, value: unknown, ttl?: number): Promise<boolean> {
|
|
102
|
-
|
|
103
|
-
|
|
102
|
+
await this.ensureDirectory()
|
|
103
|
+
|
|
104
|
+
const targetPath = this.path(key)
|
|
105
|
+
|
|
106
|
+
// Check if key already exists and is not expired
|
|
107
|
+
const file = Bun.file(targetPath)
|
|
108
|
+
if (await file.exists()) {
|
|
109
|
+
try {
|
|
110
|
+
const payload: FileCachePayload = await file.json()
|
|
111
|
+
if (payload.expiresAt === null || Date.now() <= payload.expiresAt) {
|
|
112
|
+
return false
|
|
113
|
+
}
|
|
114
|
+
// Expired — fall through to overwrite
|
|
115
|
+
} catch {
|
|
116
|
+
// Corrupted file — fall through to overwrite
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Write to a temp file first, then atomically rename into place
|
|
121
|
+
const tmpPath = join(this.directory, `_tmp_${randomBytes(8).toString('hex')}.cache`)
|
|
122
|
+
const payload: FileCachePayload = {
|
|
123
|
+
value,
|
|
124
|
+
expiresAt: ttl != null ? Date.now() + ttl * 1000 : null,
|
|
125
|
+
}
|
|
126
|
+
await Bun.write(tmpPath, JSON.stringify(payload))
|
|
127
|
+
|
|
128
|
+
try {
|
|
129
|
+
await rename(tmpPath, targetPath)
|
|
130
|
+
} catch {
|
|
131
|
+
// Cleanup temp file on failure
|
|
132
|
+
try { await rm(tmpPath) } catch { /* ignore */ }
|
|
133
|
+
return false
|
|
134
|
+
}
|
|
135
|
+
|
|
104
136
|
return true
|
|
105
137
|
}
|
|
106
138
|
|
package/src/contracts/Request.ts
CHANGED
|
@@ -11,8 +11,8 @@ export interface MantiqRequest {
|
|
|
11
11
|
// ── Input ────────────────────────────────────────────────────────────────
|
|
12
12
|
query(key: string, defaultValue?: string): string
|
|
13
13
|
query(): Record<string, string>
|
|
14
|
-
input(key: string, defaultValue?: any): Promise<
|
|
15
|
-
input
|
|
14
|
+
input<T = any>(key: string, defaultValue?: any): Promise<T>
|
|
15
|
+
input<T = Record<string, any>>(): Promise<T>
|
|
16
16
|
only(...keys: string[]): Promise<Record<string, any>>
|
|
17
17
|
except(...keys: string[]): Promise<Record<string, any>>
|
|
18
18
|
has(...keys: string[]): boolean
|
|
@@ -42,6 +42,7 @@ export interface MantiqRequest {
|
|
|
42
42
|
param(key: string, defaultValue?: any): any
|
|
43
43
|
params(): Record<string, any>
|
|
44
44
|
setRouteParams(params: Record<string, any>): void
|
|
45
|
+
setRouteParam(key: string, value: any): void
|
|
45
46
|
|
|
46
47
|
// ── Session ──────────────────────────────────────────────────────────────
|
|
47
48
|
session(): SessionStore
|
package/src/contracts/Router.ts
CHANGED
|
@@ -21,6 +21,7 @@ export interface RouteMatch {
|
|
|
21
21
|
params: Record<string, any>
|
|
22
22
|
middleware: string[]
|
|
23
23
|
routeName?: string | undefined
|
|
24
|
+
bindings?: Map<string, { model: any; key: string }>
|
|
24
25
|
}
|
|
25
26
|
|
|
26
27
|
export interface RouteDefinition {
|
|
@@ -60,4 +61,5 @@ export interface RouterRoute {
|
|
|
60
61
|
whereNumber(param: string): this
|
|
61
62
|
whereAlpha(param: string): this
|
|
62
63
|
whereUuid(param: string): this
|
|
64
|
+
bind(param: string, model: any, key?: string): this
|
|
63
65
|
}
|
package/src/encryption/errors.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { MantiqError } from '../errors/MantiqError.ts'
|
|
2
|
+
import { ErrorCodes } from '../errors/ErrorCodes.ts'
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Thrown when encryption fails (e.g. invalid key, algorithm error).
|
|
5
6
|
*/
|
|
6
7
|
export class EncryptionError extends MantiqError {
|
|
7
8
|
constructor(message = 'Could not encrypt the data.', context?: Record<string, any>) {
|
|
8
|
-
super(message, context)
|
|
9
|
+
super(message, context, ErrorCodes.ENCRYPTION_FAILED)
|
|
9
10
|
}
|
|
10
11
|
}
|
|
11
12
|
|
|
@@ -14,7 +15,7 @@ export class EncryptionError extends MantiqError {
|
|
|
14
15
|
*/
|
|
15
16
|
export class DecryptionError extends MantiqError {
|
|
16
17
|
constructor(message = 'Could not decrypt the data.', context?: Record<string, any>) {
|
|
17
|
-
super(message, context)
|
|
18
|
+
super(message, context, ErrorCodes.DECRYPTION_FAILED)
|
|
18
19
|
}
|
|
19
20
|
}
|
|
20
21
|
|
|
@@ -25,6 +26,8 @@ export class MissingAppKeyError extends MantiqError {
|
|
|
25
26
|
constructor() {
|
|
26
27
|
super(
|
|
27
28
|
'No application encryption key has been specified. Set the APP_KEY environment variable (use "base64:<key>" format for raw keys).',
|
|
29
|
+
undefined,
|
|
30
|
+
ErrorCodes.MISSING_APP_KEY,
|
|
28
31
|
)
|
|
29
32
|
}
|
|
30
33
|
}
|
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import { MantiqError } from './MantiqError.ts'
|
|
2
|
+
import { ErrorCodes } from './ErrorCodes.ts'
|
|
2
3
|
|
|
3
4
|
export class ConfigKeyNotFoundError extends MantiqError {
|
|
4
5
|
constructor(public readonly key: string) {
|
|
5
|
-
super(
|
|
6
|
+
super(
|
|
7
|
+
`Config key '${key}' not found and no default value provided.`,
|
|
8
|
+
undefined,
|
|
9
|
+
ErrorCodes.CONFIG_NOT_FOUND,
|
|
10
|
+
)
|
|
6
11
|
}
|
|
7
12
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { MantiqError } from './MantiqError.ts'
|
|
2
|
+
import { ErrorCodes } from './ErrorCodes.ts'
|
|
2
3
|
|
|
3
4
|
export class ContainerResolutionError extends MantiqError {
|
|
4
5
|
constructor(
|
|
@@ -8,6 +9,8 @@ export class ContainerResolutionError extends MantiqError {
|
|
|
8
9
|
) {
|
|
9
10
|
super(
|
|
10
11
|
`Cannot resolve ${String(abstract)}: ${reason}.${details ? ' ' + details : ''}`,
|
|
12
|
+
undefined,
|
|
13
|
+
ErrorCodes.CONTAINER_RESOLUTION,
|
|
11
14
|
)
|
|
12
15
|
}
|
|
13
16
|
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structured error codes for all MantiqJS framework errors.
|
|
3
|
+
*
|
|
4
|
+
* Each error code follows the pattern `E_{CATEGORY}_{DESCRIPTION}`.
|
|
5
|
+
*/
|
|
6
|
+
export const ErrorCodes = {
|
|
7
|
+
AUTH_UNAUTHENTICATED: 'E_AUTH_UNAUTHENTICATED',
|
|
8
|
+
AUTH_UNAUTHORIZED: 'E_AUTH_UNAUTHORIZED',
|
|
9
|
+
AUTH_FORBIDDEN: 'E_AUTH_FORBIDDEN',
|
|
10
|
+
VALIDATION_FAILED: 'E_VALIDATION_FAILED',
|
|
11
|
+
MODEL_NOT_FOUND: 'E_MODEL_NOT_FOUND',
|
|
12
|
+
ROUTE_NOT_FOUND: 'E_ROUTE_NOT_FOUND',
|
|
13
|
+
CSRF_MISMATCH: 'E_CSRF_MISMATCH',
|
|
14
|
+
ENCRYPTION_FAILED: 'E_ENCRYPTION_FAILED',
|
|
15
|
+
DECRYPTION_FAILED: 'E_DECRYPTION_FAILED',
|
|
16
|
+
MISSING_APP_KEY: 'E_MISSING_APP_KEY',
|
|
17
|
+
RATE_LIMITED: 'E_RATE_LIMITED',
|
|
18
|
+
CONTAINER_RESOLUTION: 'E_CONTAINER_RESOLUTION',
|
|
19
|
+
CONFIG_NOT_FOUND: 'E_CONFIG_NOT_FOUND',
|
|
20
|
+
QUERY_ERROR: 'E_QUERY_ERROR',
|
|
21
|
+
CONNECTION_LOST: 'E_CONNECTION_LOST',
|
|
22
|
+
CONNECTION_FAILED: 'E_CONNECTION_FAILED',
|
|
23
|
+
DRIVER_NOT_SUPPORTED: 'E_DRIVER_NOT_SUPPORTED',
|
|
24
|
+
HTTP_ERROR: 'E_HTTP_ERROR',
|
|
25
|
+
} as const
|
|
26
|
+
|
|
27
|
+
export type ErrorCode = typeof ErrorCodes[keyof typeof ErrorCodes]
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { HttpError } from './HttpError.ts'
|
|
2
|
+
import { ErrorCodes } from './ErrorCodes.ts'
|
|
2
3
|
|
|
3
4
|
export class ForbiddenError extends HttpError {
|
|
4
5
|
constructor(message = 'Forbidden', headers?: Record<string, string>) {
|
|
5
|
-
super(403, message, headers)
|
|
6
|
+
super(403, message, headers, undefined, ErrorCodes.AUTH_FORBIDDEN)
|
|
6
7
|
}
|
|
7
8
|
}
|
package/src/errors/HttpError.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { MantiqError } from './MantiqError.ts'
|
|
2
|
+
import { ErrorCodes } from './ErrorCodes.ts'
|
|
3
|
+
import type { ErrorCode } from './ErrorCodes.ts'
|
|
2
4
|
|
|
3
5
|
/**
|
|
4
6
|
* Base class for all HTTP-facing errors.
|
|
@@ -10,7 +12,8 @@ export class HttpError extends MantiqError {
|
|
|
10
12
|
message: string,
|
|
11
13
|
public readonly headers?: Record<string, string>,
|
|
12
14
|
context?: Record<string, any>,
|
|
15
|
+
errorCode?: ErrorCode | string,
|
|
13
16
|
) {
|
|
14
|
-
super(message, context)
|
|
17
|
+
super(message, context, errorCode ?? ErrorCodes.HTTP_ERROR)
|
|
15
18
|
}
|
|
16
19
|
}
|
|
@@ -1,14 +1,25 @@
|
|
|
1
|
+
import type { ErrorCode } from './ErrorCodes.ts'
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* Base error class for all MantiqJS errors.
|
|
3
|
-
* All packages must throw subclasses of this
|
|
5
|
+
* All packages must throw subclasses of this -- never raw Error.
|
|
4
6
|
*/
|
|
5
7
|
export class MantiqError extends Error {
|
|
8
|
+
/**
|
|
9
|
+
* Machine-readable error code for structured error handling.
|
|
10
|
+
* Subclasses should set this to the appropriate `ErrorCodes.*` value.
|
|
11
|
+
* Accepts any string to allow packages like OAuth to use spec-defined codes.
|
|
12
|
+
*/
|
|
13
|
+
public readonly errorCode: ErrorCode | string | undefined
|
|
14
|
+
|
|
6
15
|
constructor(
|
|
7
16
|
message: string,
|
|
8
17
|
public readonly context?: Record<string, any>,
|
|
18
|
+
errorCode?: ErrorCode | string,
|
|
9
19
|
) {
|
|
10
20
|
super(message)
|
|
11
21
|
this.name = this.constructor.name
|
|
22
|
+
this.errorCode = errorCode
|
|
12
23
|
if (Error.captureStackTrace) {
|
|
13
24
|
Error.captureStackTrace(this, this.constructor)
|
|
14
25
|
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { HttpError } from './HttpError.ts'
|
|
2
|
+
import { ErrorCodes } from './ErrorCodes.ts'
|
|
2
3
|
|
|
3
4
|
export class NotFoundError extends HttpError {
|
|
4
5
|
constructor(message = 'Not Found', headers?: Record<string, string>) {
|
|
5
|
-
super(404, message, headers)
|
|
6
|
+
super(404, message, headers, undefined, ErrorCodes.ROUTE_NOT_FOUND)
|
|
6
7
|
}
|
|
7
8
|
}
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { HttpError } from './HttpError.ts'
|
|
2
|
+
import { ErrorCodes } from './ErrorCodes.ts'
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Thrown when the CSRF token is missing or invalid.
|
|
5
6
|
*/
|
|
6
7
|
export class TokenMismatchError extends HttpError {
|
|
7
8
|
constructor(message = 'CSRF token mismatch.') {
|
|
8
|
-
super(419, message)
|
|
9
|
+
super(419, message, undefined, undefined, ErrorCodes.CSRF_MISMATCH)
|
|
9
10
|
}
|
|
10
11
|
}
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { HttpError } from './HttpError.ts'
|
|
2
|
+
import { ErrorCodes } from './ErrorCodes.ts'
|
|
2
3
|
|
|
3
4
|
export class TooManyRequestsError extends HttpError {
|
|
4
5
|
constructor(
|
|
5
6
|
message = 'Too Many Requests',
|
|
6
7
|
public readonly retryAfter?: number,
|
|
7
8
|
) {
|
|
8
|
-
super(429, message, retryAfter ? { 'Retry-After': String(retryAfter) } : undefined)
|
|
9
|
+
super(429, message, retryAfter ? { 'Retry-After': String(retryAfter) } : undefined, undefined, ErrorCodes.RATE_LIMITED)
|
|
9
10
|
}
|
|
10
11
|
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { HttpError } from './HttpError.ts'
|
|
2
|
+
import { ErrorCodes } from './ErrorCodes.ts'
|
|
2
3
|
|
|
3
4
|
export class UnauthorizedError extends HttpError {
|
|
4
5
|
constructor(message = 'Unauthorized', headers?: Record<string, string>) {
|
|
5
|
-
super(401, message, headers)
|
|
6
|
+
super(401, message, headers, undefined, ErrorCodes.AUTH_UNAUTHENTICATED)
|
|
6
7
|
}
|
|
7
8
|
}
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { HttpError } from './HttpError.ts'
|
|
2
|
+
import { ErrorCodes } from './ErrorCodes.ts'
|
|
2
3
|
|
|
3
4
|
export class ValidationError extends HttpError {
|
|
4
5
|
constructor(
|
|
5
6
|
public readonly errors: Record<string, string[]>,
|
|
6
7
|
message = 'The given data was invalid.',
|
|
7
8
|
) {
|
|
8
|
-
super(422, message, undefined, { errors })
|
|
9
|
+
super(422, message, undefined, { errors }, ErrorCodes.VALIDATION_FAILED)
|
|
9
10
|
}
|
|
10
11
|
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { Application } from '../application/Application.ts'
|
|
2
|
+
import type { AesEncrypter } from '../encryption/Encrypter.ts'
|
|
3
|
+
import { UrlSigner } from '../url/UrlSigner.ts'
|
|
4
|
+
import { ENCRYPTER } from './encrypt.ts'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Create a signed URL.
|
|
8
|
+
*
|
|
9
|
+
* @example const url = await signedUrl('https://example.com/verify?user=42')
|
|
10
|
+
*/
|
|
11
|
+
export async function signedUrl(url: string, expiresAt?: Date): Promise<string> {
|
|
12
|
+
const encrypter = Application.getInstance().make<AesEncrypter>(ENCRYPTER)
|
|
13
|
+
const signer = new UrlSigner(encrypter)
|
|
14
|
+
return signer.sign(url, expiresAt)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Validate a signed URL.
|
|
19
|
+
*
|
|
20
|
+
* @example const valid = await hasValidSignature('https://example.com/verify?user=42&signature=...')
|
|
21
|
+
*/
|
|
22
|
+
export async function hasValidSignature(url: string): Promise<boolean> {
|
|
23
|
+
const encrypter = Application.getInstance().make<AesEncrypter>(ENCRYPTER)
|
|
24
|
+
const signer = new UrlSigner(encrypter)
|
|
25
|
+
return signer.validate(url)
|
|
26
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { Application } from '../application/Application.ts'
|
|
2
|
+
import type { AesEncrypter } from '../encryption/Encrypter.ts'
|
|
3
|
+
import { UrlSigner } from '../url/UrlSigner.ts'
|
|
4
|
+
import { ENCRYPTER } from './encrypt.ts'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Create a signed URL.
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* const url = await signedUrl('https://example.com/unsubscribe?user=42')
|
|
11
|
+
* const temp = await signedUrl('https://example.com/download', new Date(Date.now() + 3600_000))
|
|
12
|
+
*/
|
|
13
|
+
export async function signedUrl(url: string, expiresAt?: Date): Promise<string> {
|
|
14
|
+
const encrypter = Application.getInstance().make<AesEncrypter>(ENCRYPTER)
|
|
15
|
+
const signer = new UrlSigner(encrypter)
|
|
16
|
+
return signer.sign(url, expiresAt)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Validate a signed URL: verify signature and check expiration.
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* if (await hasValidSignature(request.fullUrl())) {
|
|
24
|
+
* // URL is authentic and not expired
|
|
25
|
+
* }
|
|
26
|
+
*/
|
|
27
|
+
export async function hasValidSignature(url: string): Promise<boolean> {
|
|
28
|
+
const encrypter = Application.getInstance().make<AesEncrypter>(ENCRYPTER)
|
|
29
|
+
const signer = new UrlSigner(encrypter)
|
|
30
|
+
return signer.validate(url)
|
|
31
|
+
}
|
package/src/http/Kernel.ts
CHANGED
|
@@ -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
|
package/src/http/Request.ts
CHANGED
|
@@ -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
|
|
62
|
-
async input(key: string, defaultValue?: any): Promise<
|
|
63
|
-
async input(key?: string, defaultValue?: any): Promise<
|
|
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'
|
|
@@ -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}" —
|
|
54
|
+
console.warn(`[Mantiq] Failed to decrypt cookie "${name}" — discarding value`)
|
|
53
55
|
}
|
|
54
|
-
decrypted[name] =
|
|
56
|
+
decrypted[name] = ''
|
|
55
57
|
}
|
|
56
58
|
}
|
|
57
59
|
|
|
@@ -86,6 +88,13 @@ 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
100
|
// If encryption fails, send unencrypted (shouldn't happen with valid key)
|
|
@@ -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
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -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) {
|
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
|
|
|
@@ -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
|
+
}
|
|
@@ -94,7 +94,9 @@ export class WebSocketKernel {
|
|
|
94
94
|
try {
|
|
95
95
|
decrypted[name] = await this.encrypter!.decrypt(value)
|
|
96
96
|
} catch {
|
|
97
|
-
|
|
97
|
+
// Can't decrypt — expired key, tampered, or wrong format.
|
|
98
|
+
// Don't pass through the encrypted blob; discard it.
|
|
99
|
+
decrypted[name] = ''
|
|
98
100
|
}
|
|
99
101
|
}
|
|
100
102
|
|