@mantiq/core 0.5.22 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/application/Application.ts +132 -10
- package/src/cache/FileCacheStore.ts +73 -9
- package/src/cache/MemoryCacheStore.ts +8 -0
- 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/exceptions/Handler.ts +10 -2
- 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/Cors.ts +21 -11
- package/src/middleware/EncryptCookies.ts +15 -5
- package/src/middleware/RouteModelBinding.ts +43 -0
- package/src/middleware/StartSession.ts +10 -0
- package/src/middleware/TimeoutMiddleware.ts +47 -0
- package/src/middleware/VerifyCsrfToken.ts +12 -7
- package/src/providers/CoreServiceProvider.ts +26 -0
- package/src/routing/Route.ts +11 -0
- package/src/routing/Router.ts +32 -1
- package/src/session/SessionManager.ts +2 -1
- package/src/session/Store.ts +6 -1
- package/src/url/UrlSigner.ts +131 -0
- package/src/websocket/WebSocketKernel.ts +58 -2
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
|
/**
|
|
@@ -245,14 +298,22 @@ export class Application extends ContainerImpl {
|
|
|
245
298
|
/**
|
|
246
299
|
* Boot all registered (non-deferred) providers.
|
|
247
300
|
* Called after ALL providers have been registered.
|
|
301
|
+
*
|
|
302
|
+
* Errors are re-thrown with descriptive messages so they surface
|
|
303
|
+
* immediately rather than being silently swallowed.
|
|
248
304
|
*/
|
|
249
305
|
async bootProviders(): Promise<void> {
|
|
250
306
|
for (const provider of this.providers) {
|
|
307
|
+
const name = provider.constructor?.name ?? 'Unknown'
|
|
251
308
|
try {
|
|
252
309
|
await provider.boot()
|
|
253
310
|
} catch (e) {
|
|
254
|
-
|
|
255
|
-
|
|
311
|
+
// Re-throw with context so the developer knows which provider failed.
|
|
312
|
+
// Previously errors were only logged, masking critical boot failures.
|
|
313
|
+
throw new Error(
|
|
314
|
+
`[Mantiq] ${name}.boot() failed: ${(e as Error)?.message ?? e}`,
|
|
315
|
+
{ cause: e },
|
|
316
|
+
)
|
|
256
317
|
}
|
|
257
318
|
}
|
|
258
319
|
this.booted = true
|
|
@@ -277,6 +338,10 @@ export class Application extends ContainerImpl {
|
|
|
277
338
|
/**
|
|
278
339
|
* Override make() to handle deferred provider loading.
|
|
279
340
|
* If a binding isn't found in the container, check deferred providers.
|
|
341
|
+
*
|
|
342
|
+
* Deferred providers are registered and booted synchronously where possible.
|
|
343
|
+
* If register() or boot() return a Promise, this method will throw — callers
|
|
344
|
+
* must use makeAsync() for providers that require async initialization.
|
|
280
345
|
*/
|
|
281
346
|
override make<T>(abstract: Bindable<T>): T {
|
|
282
347
|
try {
|
|
@@ -292,17 +357,74 @@ export class Application extends ContainerImpl {
|
|
|
292
357
|
for (const [key, p] of this.deferredProviders.entries()) {
|
|
293
358
|
if (p === deferredProvider) this.deferredProviders.delete(key)
|
|
294
359
|
}
|
|
295
|
-
|
|
296
|
-
const
|
|
360
|
+
|
|
361
|
+
const providerName = deferredProvider.constructor?.name ?? 'DeferredProvider'
|
|
362
|
+
|
|
363
|
+
// Register the deferred provider synchronously. If register()
|
|
364
|
+
// returns a Promise, it means the provider requires async init —
|
|
365
|
+
// we cannot silently fire-and-forget because make() would return
|
|
366
|
+
// before the binding is registered.
|
|
367
|
+
const registerResult = deferredProvider.register()
|
|
368
|
+
if (registerResult && typeof (registerResult as any).then === 'function') {
|
|
369
|
+
throw new Error(
|
|
370
|
+
`[Mantiq] ${providerName}.register() is async but was triggered via synchronous make(). ` +
|
|
371
|
+
`Use await app.makeAsync(${String((abstract as any)?.name ?? abstract)}) instead.`,
|
|
372
|
+
)
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// Boot the deferred provider synchronously.
|
|
376
|
+
const bootResult = deferredProvider.boot()
|
|
377
|
+
if (bootResult && typeof (bootResult as any).then === 'function') {
|
|
378
|
+
throw new Error(
|
|
379
|
+
`[Mantiq] ${providerName}.boot() is async but was triggered via synchronous make(). ` +
|
|
380
|
+
`Use await app.makeAsync(${String((abstract as any)?.name ?? abstract)}) instead.`,
|
|
381
|
+
)
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
this.providers.push(deferredProvider)
|
|
385
|
+
return super.make(abstract)
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
throw err
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Async version of make() that properly awaits deferred provider boot.
|
|
394
|
+
*
|
|
395
|
+
* Use this when the deferred provider's register() or boot() may be async.
|
|
396
|
+
* For synchronous providers, make() is sufficient and faster.
|
|
397
|
+
*/
|
|
398
|
+
async makeAsync<T>(abstract: Bindable<T>): Promise<T> {
|
|
399
|
+
try {
|
|
400
|
+
return super.make(abstract)
|
|
401
|
+
} catch (err) {
|
|
402
|
+
if (
|
|
403
|
+
err instanceof ContainerResolutionError &&
|
|
404
|
+
err.reason === 'not_bound'
|
|
405
|
+
) {
|
|
406
|
+
const deferredProvider = this.deferredProviders.get(abstract)
|
|
407
|
+
if (deferredProvider) {
|
|
408
|
+
// Remove from deferred map so we don't loop
|
|
409
|
+
for (const [key, p] of this.deferredProviders.entries()) {
|
|
410
|
+
if (p === deferredProvider) this.deferredProviders.delete(key)
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const providerName = deferredProvider.constructor?.name ?? 'DeferredProvider'
|
|
414
|
+
|
|
415
|
+
// Await register + boot so the binding is fully available
|
|
416
|
+
// before we resolve it. Errors propagate to the caller.
|
|
417
|
+
try {
|
|
297
418
|
await deferredProvider.register()
|
|
298
419
|
await deferredProvider.boot()
|
|
299
|
-
|
|
420
|
+
} catch (e) {
|
|
421
|
+
throw new Error(
|
|
422
|
+
`[Mantiq] ${providerName} deferred boot failed: ${(e as Error)?.message ?? e}`,
|
|
423
|
+
{ cause: e },
|
|
424
|
+
)
|
|
300
425
|
}
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
const name = deferredProvider.constructor?.name ?? 'DeferredProvider'
|
|
304
|
-
console.error(`[Mantiq] ${name} deferred boot failed:`, (e as Error)?.stack ?? e)
|
|
305
|
-
})
|
|
426
|
+
|
|
427
|
+
this.providers.push(deferredProvider)
|
|
306
428
|
return super.make(abstract)
|
|
307
429
|
}
|
|
308
430
|
}
|
|
@@ -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
|
|
@@ -80,10 +81,42 @@ export class FileCacheStore implements CacheStore {
|
|
|
80
81
|
}
|
|
81
82
|
}
|
|
82
83
|
|
|
84
|
+
/**
|
|
85
|
+
* Fix #194: Atomic increment using write-to-temp + rename to prevent
|
|
86
|
+
* TOCTOU race conditions where concurrent increments could lose updates.
|
|
87
|
+
*/
|
|
83
88
|
async increment(key: string, value = 1): Promise<number> {
|
|
84
|
-
|
|
85
|
-
const
|
|
86
|
-
|
|
89
|
+
await this.ensureDirectory()
|
|
90
|
+
const targetPath = this.path(key)
|
|
91
|
+
const file = Bun.file(targetPath)
|
|
92
|
+
|
|
93
|
+
let current = 0
|
|
94
|
+
if (await file.exists()) {
|
|
95
|
+
try {
|
|
96
|
+
const payload: FileCachePayload = await file.json()
|
|
97
|
+
if (payload.expiresAt !== null && Date.now() > payload.expiresAt) {
|
|
98
|
+
// Expired — treat as 0
|
|
99
|
+
} else {
|
|
100
|
+
current = (payload.value as number) ?? 0
|
|
101
|
+
}
|
|
102
|
+
} catch {
|
|
103
|
+
// Corrupted file — start from 0
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const newValue = current + value
|
|
108
|
+
|
|
109
|
+
// Atomic write: temp file + rename to avoid partial reads by concurrent operations
|
|
110
|
+
const tmpPath = join(this.directory, `_tmp_${randomBytes(8).toString('hex')}.cache`)
|
|
111
|
+
const payload: FileCachePayload = { value: newValue, expiresAt: null }
|
|
112
|
+
await Bun.write(tmpPath, JSON.stringify(payload))
|
|
113
|
+
|
|
114
|
+
try {
|
|
115
|
+
await rename(tmpPath, targetPath)
|
|
116
|
+
} catch {
|
|
117
|
+
try { await rm(tmpPath) } catch { /* ignore */ }
|
|
118
|
+
}
|
|
119
|
+
|
|
87
120
|
return newValue
|
|
88
121
|
}
|
|
89
122
|
|
|
@@ -94,13 +127,44 @@ export class FileCacheStore implements CacheStore {
|
|
|
94
127
|
/**
|
|
95
128
|
* Store an item in the cache if the key does not already exist.
|
|
96
129
|
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
* add semantics, use the Redis or Memcached cache driver.
|
|
130
|
+
* Uses write-to-temp + rename to avoid TOCTOU race conditions.
|
|
131
|
+
* If the key already exists (and is not expired), returns false.
|
|
100
132
|
*/
|
|
101
133
|
async add(key: string, value: unknown, ttl?: number): Promise<boolean> {
|
|
102
|
-
|
|
103
|
-
|
|
134
|
+
await this.ensureDirectory()
|
|
135
|
+
|
|
136
|
+
const targetPath = this.path(key)
|
|
137
|
+
|
|
138
|
+
// Check if key already exists and is not expired
|
|
139
|
+
const file = Bun.file(targetPath)
|
|
140
|
+
if (await file.exists()) {
|
|
141
|
+
try {
|
|
142
|
+
const payload: FileCachePayload = await file.json()
|
|
143
|
+
if (payload.expiresAt === null || Date.now() <= payload.expiresAt) {
|
|
144
|
+
return false
|
|
145
|
+
}
|
|
146
|
+
// Expired — fall through to overwrite
|
|
147
|
+
} catch {
|
|
148
|
+
// Corrupted file — fall through to overwrite
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Write to a temp file first, then atomically rename into place
|
|
153
|
+
const tmpPath = join(this.directory, `_tmp_${randomBytes(8).toString('hex')}.cache`)
|
|
154
|
+
const payload: FileCachePayload = {
|
|
155
|
+
value,
|
|
156
|
+
expiresAt: ttl != null ? Date.now() + ttl * 1000 : null,
|
|
157
|
+
}
|
|
158
|
+
await Bun.write(tmpPath, JSON.stringify(payload))
|
|
159
|
+
|
|
160
|
+
try {
|
|
161
|
+
await rename(tmpPath, targetPath)
|
|
162
|
+
} catch {
|
|
163
|
+
// Cleanup temp file on failure
|
|
164
|
+
try { await rm(tmpPath) } catch { /* ignore */ }
|
|
165
|
+
return false
|
|
166
|
+
}
|
|
167
|
+
|
|
104
168
|
return true
|
|
105
169
|
}
|
|
106
170
|
|
|
@@ -43,6 +43,14 @@ export class MemoryCacheStore implements CacheStore {
|
|
|
43
43
|
this.store.clear()
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
/**
|
|
47
|
+
* Increment a cached numeric value.
|
|
48
|
+
*
|
|
49
|
+
* Note (#194): This get-then-put is safe in single-threaded Bun because
|
|
50
|
+
* there is no I/O between the read and write — the event loop cannot
|
|
51
|
+
* yield to another task mid-execution. If Bun gains multi-threaded
|
|
52
|
+
* workers sharing memory, this would need a lock or atomic operation.
|
|
53
|
+
*/
|
|
46
54
|
async increment(key: string, value = 1): Promise<number> {
|
|
47
55
|
const current = await this.get<number>(key)
|
|
48
56
|
const newValue = (current ?? 0) + value
|
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
|
}
|
|
@@ -8,6 +8,11 @@ import { UnauthorizedError } from '../errors/UnauthorizedError.ts'
|
|
|
8
8
|
import { MantiqResponse } from '../http/Response.ts'
|
|
9
9
|
import { renderDevErrorPage } from './DevErrorPage.ts'
|
|
10
10
|
|
|
11
|
+
/** Security: escape HTML special characters to prevent XSS in error pages. */
|
|
12
|
+
function escapeHtml(s: string): string {
|
|
13
|
+
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''')
|
|
14
|
+
}
|
|
15
|
+
|
|
11
16
|
export class DefaultExceptionHandler implements ExceptionHandler {
|
|
12
17
|
dontReport: Constructor<Error>[] = [
|
|
13
18
|
NotFoundError,
|
|
@@ -102,12 +107,15 @@ export class DefaultExceptionHandler implements ExceptionHandler {
|
|
|
102
107
|
}
|
|
103
108
|
|
|
104
109
|
private genericHtmlPage(status: number, message: string): string {
|
|
110
|
+
// Security: escape message to prevent XSS — error messages may contain
|
|
111
|
+
// user-controlled input (e.g., URL paths, query parameters).
|
|
112
|
+
const safeMessage = escapeHtml(message)
|
|
105
113
|
return `<!DOCTYPE html>
|
|
106
114
|
<html lang="en">
|
|
107
115
|
<head>
|
|
108
116
|
<meta charset="UTF-8">
|
|
109
117
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
110
|
-
<title>${status} ${
|
|
118
|
+
<title>${status} ${safeMessage}</title>
|
|
111
119
|
<style>
|
|
112
120
|
body { font-family: system-ui, sans-serif; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; background: #f7fafc; color: #2d3748; }
|
|
113
121
|
.box { text-align: center; }
|
|
@@ -118,7 +126,7 @@ export class DefaultExceptionHandler implements ExceptionHandler {
|
|
|
118
126
|
<body>
|
|
119
127
|
<div class="box">
|
|
120
128
|
<h1>${status}</h1>
|
|
121
|
-
<p>${
|
|
129
|
+
<p>${safeMessage}</p>
|
|
122
130
|
</div>
|
|
123
131
|
</body>
|
|
124
132
|
</html>`
|
|
@@ -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
|
+
}
|