@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
package/package.json
CHANGED
|
@@ -189,9 +189,7 @@ export class Application extends ContainerImpl {
|
|
|
189
189
|
providers.push(mod[providerName])
|
|
190
190
|
}
|
|
191
191
|
} catch (e) {
|
|
192
|
-
|
|
193
|
-
console.warn(`[Mantiq] Failed to load provider from ${file}:`, (e as Error)?.message ?? e)
|
|
194
|
-
}
|
|
192
|
+
console.warn(`[Mantiq] Failed to load provider from ${file}:`, (e as Error)?.message ?? e)
|
|
195
193
|
}
|
|
196
194
|
}
|
|
197
195
|
} catch {
|
|
@@ -212,11 +210,64 @@ export class Application extends ContainerImpl {
|
|
|
212
210
|
coreProviders: Constructor<ServiceProvider>[] = [],
|
|
213
211
|
userProviders: Constructor<ServiceProvider>[] = [],
|
|
214
212
|
): Promise<void> {
|
|
213
|
+
this.validateEnvironment()
|
|
215
214
|
const packageProviders = await this.discoverPackageProviders()
|
|
216
215
|
await this.registerProviders([...coreProviders, ...packageProviders, ...userProviders])
|
|
217
216
|
await this.bootProviders()
|
|
218
217
|
}
|
|
219
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
|
+
|
|
220
271
|
// ── Provider lifecycle ────────────────────────────────────────────────────
|
|
221
272
|
|
|
222
273
|
/**
|
|
@@ -250,7 +301,12 @@ export class Application extends ContainerImpl {
|
|
|
250
301
|
*/
|
|
251
302
|
async bootProviders(): Promise<void> {
|
|
252
303
|
for (const provider of this.providers) {
|
|
253
|
-
|
|
304
|
+
try {
|
|
305
|
+
await provider.boot()
|
|
306
|
+
} catch (e) {
|
|
307
|
+
const name = provider.constructor?.name ?? 'Unknown'
|
|
308
|
+
console.error(`[Mantiq] ${name}.boot() failed:`, (e as Error)?.stack ?? e)
|
|
309
|
+
}
|
|
254
310
|
}
|
|
255
311
|
this.booted = true
|
|
256
312
|
}
|
|
@@ -295,9 +351,11 @@ export class Application extends ContainerImpl {
|
|
|
295
351
|
await deferredProvider.boot()
|
|
296
352
|
this.providers.push(deferredProvider)
|
|
297
353
|
}
|
|
298
|
-
//
|
|
299
|
-
|
|
300
|
-
|
|
354
|
+
// Deferred boot — catch and log errors instead of swallowing
|
|
355
|
+
boot().catch((e) => {
|
|
356
|
+
const name = deferredProvider.constructor?.name ?? 'DeferredProvider'
|
|
357
|
+
console.error(`[Mantiq] ${name} deferred boot failed:`, (e as Error)?.stack ?? e)
|
|
358
|
+
})
|
|
301
359
|
return super.make(abstract)
|
|
302
360
|
}
|
|
303
361
|
}
|
|
@@ -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
|
|
@@ -91,9 +92,47 @@ export class FileCacheStore implements CacheStore {
|
|
|
91
92
|
return this.increment(key, -value)
|
|
92
93
|
}
|
|
93
94
|
|
|
95
|
+
/**
|
|
96
|
+
* Store an item in the cache if the key does not already exist.
|
|
97
|
+
*
|
|
98
|
+
* Uses write-to-temp + rename to avoid TOCTOU race conditions.
|
|
99
|
+
* If the key already exists (and is not expired), returns false.
|
|
100
|
+
*/
|
|
94
101
|
async add(key: string, value: unknown, ttl?: number): Promise<boolean> {
|
|
95
|
-
|
|
96
|
-
|
|
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
|
+
|
|
97
136
|
return true
|
|
98
137
|
}
|
|
99
138
|
|
|
@@ -55,8 +55,20 @@ export class MemoryCacheStore implements CacheStore {
|
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
async add(key: string, value: unknown, ttl?: number): Promise<boolean> {
|
|
58
|
-
|
|
59
|
-
|
|
58
|
+
// Atomic check-and-set: read the entry directly and check expiry in one step
|
|
59
|
+
// to avoid TOCTOU race between has() and put().
|
|
60
|
+
const existing = this.store.get(key)
|
|
61
|
+
if (existing) {
|
|
62
|
+
if (existing.expiresAt === null || Date.now() <= existing.expiresAt) {
|
|
63
|
+
return false
|
|
64
|
+
}
|
|
65
|
+
// Expired — remove and allow re-add
|
|
66
|
+
this.store.delete(key)
|
|
67
|
+
}
|
|
68
|
+
this.store.set(key, {
|
|
69
|
+
value,
|
|
70
|
+
expiresAt: ttl != null ? Date.now() + ttl * 1000 : null,
|
|
71
|
+
})
|
|
60
72
|
return true
|
|
61
73
|
}
|
|
62
74
|
}
|
package/src/contracts/Request.ts
CHANGED
|
@@ -11,13 +11,17 @@ 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
|
|
19
19
|
filled(...keys: string[]): Promise<boolean>
|
|
20
20
|
|
|
21
|
+
// ── Body errors ────────────────────────────────────────────────────────
|
|
22
|
+
hasBodyError(): boolean
|
|
23
|
+
bodyError(): Error | null
|
|
24
|
+
|
|
21
25
|
// ── Headers & metadata ───────────────────────────────────────────────────
|
|
22
26
|
header(key: string, defaultValue?: string): string | undefined
|
|
23
27
|
headers(): Record<string, string>
|
|
@@ -38,6 +42,7 @@ export interface MantiqRequest {
|
|
|
38
42
|
param(key: string, defaultValue?: any): any
|
|
39
43
|
params(): Record<string, any>
|
|
40
44
|
setRouteParams(params: Record<string, any>): void
|
|
45
|
+
setRouteParam(key: string, value: any): void
|
|
41
46
|
|
|
42
47
|
// ── Session ──────────────────────────────────────────────────────────────
|
|
43
48
|
session(): SessionStore
|
|
@@ -50,6 +55,9 @@ export interface MantiqRequest {
|
|
|
50
55
|
isAuthenticated(): boolean
|
|
51
56
|
setUser(user: any): void
|
|
52
57
|
|
|
58
|
+
// ── FormData ────────────────────────────────────────────────────────────
|
|
59
|
+
formData(): Promise<FormData>
|
|
60
|
+
|
|
53
61
|
// ── Raw ──────────────────────────────────────────────────────────────────
|
|
54
62
|
raw(): Request
|
|
55
63
|
}
|
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
|
}
|
|
@@ -156,9 +156,7 @@ export class Discoverer {
|
|
|
156
156
|
mod.default(router)
|
|
157
157
|
}
|
|
158
158
|
} catch (e) {
|
|
159
|
-
|
|
160
|
-
console.warn(`[Mantiq] Failed to load route file ${file}:`, (e as Error)?.message ?? e)
|
|
161
|
-
}
|
|
159
|
+
console.warn(`[Mantiq] Failed to load route file ${file}:`, (e as Error)?.message ?? e)
|
|
162
160
|
}
|
|
163
161
|
}
|
|
164
162
|
}
|
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
|
}
|
|
@@ -10,6 +10,21 @@ import type { MantiqRequest } from '../contracts/Request.ts'
|
|
|
10
10
|
* - Copy as Markdown button
|
|
11
11
|
* - Source file context when available
|
|
12
12
|
*/
|
|
13
|
+
/**
|
|
14
|
+
* Security: headers that must never appear on the debug page, even in dev mode.
|
|
15
|
+
* These can contain authentication credentials, session tokens, and CSRF secrets.
|
|
16
|
+
*/
|
|
17
|
+
const SENSITIVE_HEADERS = new Set([
|
|
18
|
+
'authorization',
|
|
19
|
+
'cookie',
|
|
20
|
+
'set-cookie',
|
|
21
|
+
'x-csrf-token',
|
|
22
|
+
'x-xsrf-token',
|
|
23
|
+
'proxy-authorization',
|
|
24
|
+
'x-api-key',
|
|
25
|
+
'x-auth-token',
|
|
26
|
+
])
|
|
27
|
+
|
|
13
28
|
export function renderDevErrorPage(request: MantiqRequest, error: unknown): string {
|
|
14
29
|
const err = error instanceof Error ? error : new Error(String(error))
|
|
15
30
|
const stack = err.stack ?? err.message
|
|
@@ -51,8 +66,13 @@ export function renderDevErrorPage(request: MantiqRequest, error: unknown): stri
|
|
|
51
66
|
})
|
|
52
67
|
.join('')
|
|
53
68
|
|
|
69
|
+
// Security: redact sensitive headers to prevent leaking auth tokens,
|
|
70
|
+
// cookies, and CSRF secrets — even on the dev error page.
|
|
54
71
|
const headersHtml = Object.entries(request.headers())
|
|
55
|
-
.map(([k, v]) =>
|
|
72
|
+
.map(([k, v]) => {
|
|
73
|
+
const redacted = SENSITIVE_HEADERS.has(k.toLowerCase()) ? '********' : v
|
|
74
|
+
return `<tr><td class="td-key">${escapeHtml(k)}</td><td class="td-val">${escapeHtml(redacted)}</td></tr>`
|
|
75
|
+
})
|
|
56
76
|
.join('')
|
|
57
77
|
|
|
58
78
|
const queryString = request.fullUrl().includes('?') ? request.fullUrl().split('?')[1] : ''
|
|
@@ -63,14 +83,13 @@ export function renderDevErrorPage(request: MantiqRequest, error: unknown): stri
|
|
|
63
83
|
}).join('')
|
|
64
84
|
: '<tr><td class="td-val" colspan="2" style="opacity:.5">No query parameters</td></tr>'
|
|
65
85
|
|
|
66
|
-
// Markdown for clipboard
|
|
86
|
+
// Markdown for clipboard — also redact sensitive headers
|
|
67
87
|
const markdown = [
|
|
68
88
|
`# ${err.name}: ${err.message}`,
|
|
69
89
|
'',
|
|
70
90
|
`**Status:** ${statusCode}`,
|
|
71
91
|
`**Method:** ${request.method()}`,
|
|
72
92
|
`**URL:** ${request.fullUrl()}`,
|
|
73
|
-
`**IP:** ${request.ip()}`,
|
|
74
93
|
`**User Agent:** ${request.userAgent()}`,
|
|
75
94
|
'',
|
|
76
95
|
'## Stack Trace',
|
|
@@ -81,7 +100,10 @@ export function renderDevErrorPage(request: MantiqRequest, error: unknown): stri
|
|
|
81
100
|
'## Request Headers',
|
|
82
101
|
'| Header | Value |',
|
|
83
102
|
'|--------|-------|',
|
|
84
|
-
...Object.entries(request.headers()).map(([k, v]) =>
|
|
103
|
+
...Object.entries(request.headers()).map(([k, v]) => {
|
|
104
|
+
const redacted = SENSITIVE_HEADERS.has(k.toLowerCase()) ? '********' : v
|
|
105
|
+
return `| ${k} | ${redacted} |`
|
|
106
|
+
}),
|
|
85
107
|
'',
|
|
86
108
|
`*Bun ${bunVersion} — MantiqJS*`,
|
|
87
109
|
].join('\n')
|
|
@@ -454,7 +476,7 @@ export function renderDevErrorPage(request: MantiqRequest, error: unknown): stri
|
|
|
454
476
|
<tr><td class="td-key">Method</td><td class="td-val">${escapeHtml(request.method())}</td></tr>
|
|
455
477
|
<tr><td class="td-key">Path</td><td class="td-val">${escapeHtml(request.path())}</td></tr>
|
|
456
478
|
<tr><td class="td-key">Full URL</td><td class="td-val">${escapeHtml(request.fullUrl())}</td></tr>
|
|
457
|
-
|
|
479
|
+
<!-- Security: IP address omitted to prevent leaking client addresses in shared debug output -->
|
|
458
480
|
<tr><td class="td-key">User Agent</td><td class="td-val">${escapeHtml(request.userAgent())}</td></tr>
|
|
459
481
|
<tr><td class="td-key">Expects JSON</td><td class="td-val">${request.expectsJson() ? 'Yes' : 'No'}</td></tr>
|
|
460
482
|
<tr><td class="td-key">Authenticated</td><td class="td-val">${request.isAuthenticated() ? 'Yes' : 'No'}</td></tr>
|
|
@@ -85,6 +85,7 @@ export class DefaultExceptionHandler implements ExceptionHandler {
|
|
|
85
85
|
): Response {
|
|
86
86
|
// API routes always get JSON — even in debug mode
|
|
87
87
|
if (request.expectsJson()) {
|
|
88
|
+
// Security: in production (debug=false), never expose error details
|
|
88
89
|
const body: Record<string, any> = { error: { message: 'Internal Server Error', status: 500 } }
|
|
89
90
|
if (debug && err.stack) {
|
|
90
91
|
body['error']['message'] = err.message
|
|
@@ -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
|
+
}
|