@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.
Files changed (37) hide show
  1. package/package.json +1 -1
  2. package/src/application/Application.ts +132 -10
  3. package/src/cache/FileCacheStore.ts +73 -9
  4. package/src/cache/MemoryCacheStore.ts +8 -0
  5. package/src/contracts/Request.ts +3 -2
  6. package/src/contracts/Router.ts +2 -0
  7. package/src/encryption/errors.ts +5 -2
  8. package/src/errors/ConfigKeyNotFoundError.ts +6 -1
  9. package/src/errors/ContainerResolutionError.ts +3 -0
  10. package/src/errors/ErrorCodes.ts +27 -0
  11. package/src/errors/ForbiddenError.ts +2 -1
  12. package/src/errors/HttpError.ts +4 -1
  13. package/src/errors/MantiqError.ts +12 -1
  14. package/src/errors/NotFoundError.ts +2 -1
  15. package/src/errors/TokenMismatchError.ts +2 -1
  16. package/src/errors/TooManyRequestsError.ts +2 -1
  17. package/src/errors/UnauthorizedError.ts +2 -1
  18. package/src/errors/ValidationError.ts +2 -1
  19. package/src/exceptions/Handler.ts +10 -2
  20. package/src/helpers/signedUrl.ts +26 -0
  21. package/src/helpers/url.ts +31 -0
  22. package/src/http/Kernel.ts +56 -0
  23. package/src/http/Request.ts +9 -5
  24. package/src/index.ts +9 -0
  25. package/src/middleware/Cors.ts +21 -11
  26. package/src/middleware/EncryptCookies.ts +15 -5
  27. package/src/middleware/RouteModelBinding.ts +43 -0
  28. package/src/middleware/StartSession.ts +10 -0
  29. package/src/middleware/TimeoutMiddleware.ts +47 -0
  30. package/src/middleware/VerifyCsrfToken.ts +12 -7
  31. package/src/providers/CoreServiceProvider.ts +26 -0
  32. package/src/routing/Route.ts +11 -0
  33. package/src/routing/Router.ts +32 -1
  34. package/src/session/SessionManager.ts +2 -1
  35. package/src/session/Store.ts +6 -1
  36. package/src/url/UrlSigner.ts +131 -0
  37. package/src/websocket/WebSocketKernel.ts +58 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mantiq/core",
3
- "version": "0.5.22",
3
+ "version": "0.6.0",
4
4
  "description": "Service container, router, middleware, HTTP kernel, config, and exception handler",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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
- const name = provider.constructor?.name ?? 'Unknown'
255
- console.error(`[Mantiq] ${name}.boot() failed:`, (e as Error)?.stack ?? e)
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
- // Register + boot the deferred provider now
296
- const boot = async () => {
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
- this.providers.push(deferredProvider)
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
- // Deferred boot — catch and log errors instead of swallowing
302
- boot().catch((e) => {
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
- const current = await this.get<number>(key)
85
- const newValue = (current ?? 0) + value
86
- await this.put(key, newValue)
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
- * Note: File-based storage cannot guarantee atomicity. Between the existence
98
- * check and the write, another process could set the key. For truly atomic
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
- if (await this.has(key)) return false
103
- await this.put(key, value, ttl)
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
@@ -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<any>
15
- input(): Promise<Record<string, any>>
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
@@ -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
  }
@@ -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(`Config key '${key}' not found and no default value provided.`)
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
  }
@@ -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 never raw Error.
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;')
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} ${message}</title>
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>${message}</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
+ }