@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.
Files changed (40) hide show
  1. package/package.json +1 -1
  2. package/src/application/Application.ts +65 -7
  3. package/src/cache/FileCacheStore.ts +42 -3
  4. package/src/cache/MemoryCacheStore.ts +14 -2
  5. package/src/contracts/Request.ts +10 -2
  6. package/src/contracts/Router.ts +2 -0
  7. package/src/discovery/Discoverer.ts +1 -3
  8. package/src/encryption/errors.ts +5 -2
  9. package/src/errors/ConfigKeyNotFoundError.ts +6 -1
  10. package/src/errors/ContainerResolutionError.ts +3 -0
  11. package/src/errors/ErrorCodes.ts +27 -0
  12. package/src/errors/ForbiddenError.ts +2 -1
  13. package/src/errors/HttpError.ts +4 -1
  14. package/src/errors/MantiqError.ts +12 -1
  15. package/src/errors/NotFoundError.ts +2 -1
  16. package/src/errors/TokenMismatchError.ts +2 -1
  17. package/src/errors/TooManyRequestsError.ts +2 -1
  18. package/src/errors/UnauthorizedError.ts +2 -1
  19. package/src/errors/ValidationError.ts +2 -1
  20. package/src/exceptions/DevErrorPage.ts +27 -5
  21. package/src/exceptions/Handler.ts +1 -0
  22. package/src/helpers/signedUrl.ts +26 -0
  23. package/src/helpers/url.ts +31 -0
  24. package/src/http/Kernel.ts +91 -2
  25. package/src/http/Request.ts +60 -11
  26. package/src/http/Response.ts +54 -1
  27. package/src/http/UploadedFile.ts +24 -1
  28. package/src/index.ts +11 -0
  29. package/src/middleware/Cors.ts +9 -1
  30. package/src/middleware/EncryptCookies.ts +14 -2
  31. package/src/middleware/RouteModelBinding.ts +43 -0
  32. package/src/middleware/SecureHeaders.ts +72 -0
  33. package/src/middleware/TimeoutMiddleware.ts +47 -0
  34. package/src/providers/CoreServiceProvider.ts +33 -0
  35. package/src/routing/Route.ts +11 -0
  36. package/src/routing/Router.ts +32 -1
  37. package/src/session/Store.ts +2 -1
  38. package/src/support/Enum.ts +96 -0
  39. package/src/url/UrlSigner.ts +131 -0
  40. package/src/websocket/WebSocketKernel.ts +45 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mantiq/core",
3
- "version": "0.5.21",
3
+ "version": "0.5.23",
4
4
  "description": "Service container, router, middleware, HTTP kernel, config, and exception handler",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -189,9 +189,7 @@ export class Application extends ContainerImpl {
189
189
  providers.push(mod[providerName])
190
190
  }
191
191
  } catch (e) {
192
- if (process.env.APP_DEBUG === 'true') {
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
- await provider.boot()
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
- // @internal: sync wrapper deferred providers must not have async register/boot
299
- // that relies on other async operations. In practice this is fine.
300
- void boot()
354
+ // Deferred bootcatch 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
- if (await this.has(key)) return false
96
- await this.put(key, value, ttl)
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
- if (await this.has(key)) return false
59
- await this.put(key, value, ttl)
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
  }
@@ -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<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
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
  }
@@ -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
- if (process.env.APP_DEBUG === 'true') {
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
  }
@@ -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
  }
@@ -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]) => `<tr><td class="td-key">${escapeHtml(k)}</td><td class="td-val">${escapeHtml(v)}</td></tr>`)
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]) => `| ${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
- <tr><td class="td-key">IP Address</td><td class="td-val">${escapeHtml(request.ip())}</td></tr>
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
+ }