@scallywag/validation 1.0.0 → 1.0.4

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/README.md ADDED
@@ -0,0 +1,1372 @@
1
+ # Validation Module
2
+
3
+ Production-grade validation framework built on Zod. Provides schema validation, input sanitization (XSS/SQL injection prevention), Next.js API middleware, environment variable validation, security middleware, rate limiting, and Zod-to-JSON-Schema conversion.
4
+
5
+ ## Directory Structure
6
+
7
+ ```
8
+ packages/kernel/validation/
9
+ ├── index.ts # Central exports (re-exports middleware, schemas, types, validators, zod-schema-converter)
10
+ ├── types.ts # Type definitions and enums
11
+ ├── validators.ts # Core validation functions and utilities
12
+ ├── schemas.ts # Pre-built Zod schema collections
13
+ ├── sanitization.ts # XSS/SQL injection prevention and string sanitization
14
+ ├── middleware.ts # Next.js API route validation and security middleware
15
+ ├── env.ts # Environment variable validation and security scanning
16
+ └── zod-schema-converter.ts # Zod-to-JSON-Schema conversion utilities
17
+ ```
18
+
19
+ ## Architecture
20
+
21
+ ```
22
+ ┌──────────────────────────────────────────────────────────────────────┐
23
+ │ VALIDATION SYSTEM │
24
+ ├──────────────────────────────────────────────────────────────────────┤
25
+ │ │
26
+ │ ┌────────────────────────────────────────────────────────────────┐ │
27
+ │ │ Validators (Core) │ │
28
+ │ │ validateData() - Zod schema validation │ │
29
+ │ │ validateRequest() - Request validation + sanitization │ │
30
+ │ │ validateFileUpload() - File type/size validation │ │
31
+ │ │ batchValidate() - Parallel validation │ │
32
+ │ │ validateEnv() - Environment variable validation │ │
33
+ │ │ parseAndValidateJSON() - JSON parse + Zod validate │ │
34
+ │ │ createValidator() - Type guard factory │ │
35
+ │ │ createAsyncValidator() - Async type guard factory │ │
36
+ │ │ createValidationDecorator() - Method decorator factory │ │
37
+ │ │ formatValidationErrors() - Error formatter for API responses │ │
38
+ │ │ hasErrorCode() - Error code checker │ │
39
+ │ │ getFieldErrors() - Field-specific error extraction │ │
40
+ │ └────────────────────────────────────────────────────────────────┘ │
41
+ │ │
42
+ │ ┌────────────────────────────────────────────────────────────────┐ │
43
+ │ │ Sanitization │ │
44
+ │ │ containsXssPatterns() - XSS detection (no modification) │ │
45
+ │ │ sanitizeString() - String sanitization with options │ │
46
+ │ │ sanitizeObjectStrings() - Recursive deep object sanitization │ │
47
+ │ │ XSS_PATTERNS - Regex patterns for XSS vectors │ │
48
+ │ │ SQL_PATTERNS - Regex patterns for SQL injection │ │
49
+ │ │ HTML_ENTITIES - Entity encoding map │ │
50
+ │ └────────────────────────────────────────────────────────────────┘ │
51
+ │ │
52
+ │ ┌────────────────────────────────────────────────────────────────┐ │
53
+ │ │ Pre-Built Schemas │ │
54
+ │ │ primitiveSchemas - email, url, uuid, dates, numbers │ │
55
+ │ │ securitySchemas - safeString, safeFilename, strongPassword │ │
56
+ │ │ apiSchemas - pagination, apiResponse, errorResponse │ │
57
+ │ │ authSchemas - userRegistration, userLogin, jwtPayload │ │
58
+ │ │ fractalSchemas - exportConfig, testConfig, cacheConfig │ │
59
+ │ │ envSchemas - development, production │ │
60
+ │ │ fileSchemas - imageUpload, documentUpload │ │
61
+ │ └────────────────────────────────────────────────────────────────┘ │
62
+ │ │
63
+ │ ┌────────────────────────────────────────────────────────────────┐ │
64
+ │ │ Middleware │ │
65
+ │ │ createValidationMiddleware() - Body/query/header/input │ │
66
+ │ │ createSecurityMiddleware() - Bot/origin/auth checks │ │
67
+ │ │ createRateLimitMiddleware() - Per-IP rate limiting │ │
68
+ │ │ createApiMiddleware() - Comprehensive middleware stack │ │
69
+ │ │ withValidation() - HOF for route handlers │ │
70
+ │ │ validateRoute() - Method decorator for routes │ │
71
+ │ │ combineMiddleware() - Middleware combinator │ │
72
+ │ └────────────────────────────────────────────────────────────────┘ │
73
+ │ │
74
+ │ ┌────────────────────────────────────────────────────────────────┐ │
75
+ │ │ Environment Validation │ │
76
+ │ │ validateEnvironment() - NODE_ENV-aware validation │ │
77
+ │ │ scanEnvironmentSecurity() - Security issue scanning │ │
78
+ │ │ logEnvironmentValidation() - Structured result logging │ │
79
+ │ │ initializeEnvironment() - Startup validation (exits on │ │
80
+ │ │ failure) │ │
81
+ │ │ processValidationResult() - Result processing helper │ │
82
+ │ │ processValidationError() - Error processing helper │ │
83
+ │ └────────────────────────────────────────────────────────────────┘ │
84
+ │ │
85
+ │ ┌────────────────────────────────────────────────────────────────┐ │
86
+ │ │ Zod Schema Converter │ │
87
+ │ │ zodToJsonSchema() - Zod -> JSON Schema │ │
88
+ │ │ safeZodToJsonSchema() - Safe conversion (returns undef) │ │
89
+ │ │ extractMethodSchemas() - Input/output schema extraction │ │
90
+ │ │ isZodSchemaWithJsonSupport() - Runtime Zod 4 detection │ │
91
+ │ └────────────────────────────────────────────────────────────────┘ │
92
+ │ │
93
+ └──────────────────────────────────────────────────────────────────────┘
94
+ ```
95
+
96
+ ## Type Definitions
97
+
98
+ All types are defined in `types.ts`.
99
+
100
+ ### ValidationResult
101
+
102
+ Discriminated union representing success or failure:
103
+
104
+ ```typescript
105
+ type ValidationResult<T = unknown> =
106
+ | { success: true; data: T }
107
+ | { success: false; errors: ValidationError[] };
108
+ ```
109
+
110
+ ### ValidationError
111
+
112
+ ```typescript
113
+ interface ValidationError {
114
+ field: string; // Dot-notation path (e.g. "address.city")
115
+ message: string; // Human-readable error message
116
+ code: string; // Zod error code or custom code
117
+ value?: unknown; // The received value (when available)
118
+ }
119
+ ```
120
+
121
+ ### ValidationOptions
122
+
123
+ ```typescript
124
+ interface ValidationOptions {
125
+ strict?: boolean; // Fail on unknown fields
126
+ stripUnknown?: boolean; // Remove unknown fields
127
+ errorMap?: z.ZodErrorMap; // Custom Zod error messages
128
+ async?: boolean; // Use parseAsync instead of parse
129
+ }
130
+ ```
131
+
132
+ ### SanitizationOptions
133
+
134
+ ```typescript
135
+ interface SanitizationOptions {
136
+ html?: boolean; // HTML entity encoding (default: false)
137
+ sql?: boolean; // SQL injection protection (default: true in sanitizeString)
138
+ xss?: boolean; // XSS pattern removal (default: true in sanitizeString)
139
+ trim?: boolean; // Trim whitespace (default: true in sanitizeString)
140
+ lowercase?: boolean; // Convert to lowercase
141
+ uppercase?: boolean; // Convert to uppercase
142
+ }
143
+ ```
144
+
145
+ ### SecurityLevel
146
+
147
+ ```typescript
148
+ enum SecurityLevel {
149
+ LOW = 'low', // No checks (pass-through)
150
+ MEDIUM = 'medium', // Bot detection
151
+ HIGH = 'high', // Bot + automated tool detection + origin required
152
+ CRITICAL = 'critical', // Bot + automated + scripting detection + origin + auth required
153
+ }
154
+ ```
155
+
156
+ ### EndpointValidation
157
+
158
+ Configuration for validation middleware. Supports two modes: unified input or legacy separate schemas.
159
+
160
+ ```typescript
161
+ interface EndpointValidation {
162
+ input?: z.ZodSchema; // Unified: merges body + query + params into one validated object
163
+ output?: z.ZodSchema; // Response validation schema
164
+ body?: z.ZodSchema; // Legacy: request body schema
165
+ query?: z.ZodSchema; // Legacy: query parameter schema
166
+ params?: z.ZodSchema; // Legacy: URL parameter schema
167
+ headers?: z.ZodSchema; // Header validation schema (works with both modes)
168
+ response?: z.ZodSchema; // Response schema (alias for output)
169
+ }
170
+ ```
171
+
172
+ ### FractalValidationContext
173
+
174
+ ```typescript
175
+ interface FractalValidationContext {
176
+ interface: string; // e.g. 'api', 'cli', 'mcp', 'sdk'
177
+ method?: string; // HTTP method
178
+ path?: string; // Route path
179
+ securityLevel: SecurityLevel; // Required security level
180
+ userRole?: string; // Current user role
181
+ permissions?: string[]; // Current user permissions
182
+ }
183
+ ```
184
+
185
+ ### FieldMetadata
186
+
187
+ ```typescript
188
+ interface FieldMetadata {
189
+ required: boolean;
190
+ type: string;
191
+ description?: string;
192
+ examples?: unknown[];
193
+ constraints?: Record<string, unknown>;
194
+ }
195
+ ```
196
+
197
+ ### SchemaMetadata
198
+
199
+ ```typescript
200
+ interface SchemaMetadata {
201
+ name: string;
202
+ description: string;
203
+ version: string;
204
+ fields: Record<string, FieldMetadata>;
205
+ }
206
+ ```
207
+
208
+ ### ValidationRule
209
+
210
+ ```typescript
211
+ type ValidationRuleType =
212
+ | 'string'
213
+ | 'number'
214
+ | 'boolean'
215
+ | 'array'
216
+ | 'object'
217
+ | 'email'
218
+ | 'url'
219
+ | 'uuid'
220
+ | 'date'
221
+ | 'custom';
222
+
223
+ interface ValidationRule {
224
+ field: string;
225
+ type: ValidationRuleType;
226
+ required: boolean;
227
+ constraints?: {
228
+ min?: number;
229
+ max?: number;
230
+ pattern?: string;
231
+ enum?: unknown[];
232
+ custom?: (value: unknown) => boolean | string;
233
+ };
234
+ message?: string;
235
+ }
236
+ ```
237
+
238
+ ## Full API Surface
239
+
240
+ ### validators.ts
241
+
242
+ #### `validateData<T>(schema, data, options?): Promise<ValidationResult<T>>`
243
+
244
+ Core validation function. Parses data against a Zod schema, returning a typed discriminated union.
245
+
246
+ - Uses `schema.parseAsync()` when `options.async` is true, otherwise `schema.parse()`
247
+ - Maps `ZodError` issues to `ValidationError[]` with dot-notation field paths
248
+ - Non-Zod errors produce a single error with field `'unknown'` and code `'unknown_error'`
249
+
250
+ ```typescript
251
+ import { validateData } from '@scallywag/kernel/validation';
252
+ import { z } from 'zod';
253
+
254
+ const schema = z.object({
255
+ email: z.string().email(),
256
+ age: z.number().min(18),
257
+ });
258
+
259
+ const result = await validateData(schema, { email: 'a@b.com', age: 25 });
260
+ if (result.success) {
261
+ // result.data: { email: string; age: number }
262
+ }
263
+ ```
264
+
265
+ #### `validateRequest<T>(schema, data, context?): Promise<ValidationResult<T>>`
266
+
267
+ Validates data with automatic pre-sanitization. When data is an object, runs `sanitizeObjectStrings()` before validation. Sets `strict: true` when `context.securityLevel === 'critical'`.
268
+
269
+ ```typescript
270
+ import { validateRequest, SecurityLevel } from '@scallywag/kernel/validation';
271
+
272
+ const result = await validateRequest(userSchema, requestBody, {
273
+ interface: 'api',
274
+ securityLevel: SecurityLevel.HIGH,
275
+ });
276
+ ```
277
+
278
+ #### `validateFileUpload(file, allowedTypes, maxSize): ValidationResult<File>`
279
+
280
+ Synchronous file validation. Checks:
281
+
282
+ 1. File is not null/undefined
283
+ 2. `file.type` is in `allowedTypes` array
284
+ 3. `file.size` does not exceed `maxSize` (bytes)
285
+
286
+ Returns all applicable errors (not just the first).
287
+
288
+ ```typescript
289
+ import { validateFileUpload } from '@scallywag/kernel/validation';
290
+
291
+ const result = validateFileUpload(
292
+ file,
293
+ ['image/jpeg', 'image/png', 'image/webp'],
294
+ 5 * 1024 * 1024
295
+ );
296
+ ```
297
+
298
+ #### `batchValidate<T>(schema, dataArray, options?): Promise<Array<ValidationResult<T>>>`
299
+
300
+ Validates an array of values in parallel using `Promise.all`. Each element is independently validated against the same schema.
301
+
302
+ ```typescript
303
+ const results = await batchValidate(userSchema, [user1, user2, user3]);
304
+ const valid = results.filter((r) => r.success);
305
+ const invalid = results.filter((r) => !r.success);
306
+ ```
307
+
308
+ #### `validateEnv<T>(schema): T`
309
+
310
+ Parses `process.env` against a Zod schema. Throws on failure with formatted error messages showing `path: message` per issue.
311
+
312
+ ```typescript
313
+ const env = validateEnv(
314
+ z.object({
315
+ DATABASE_URL: z.string().url(),
316
+ PORT: z.coerce.number().default(3000),
317
+ })
318
+ );
319
+ ```
320
+
321
+ #### `createValidator<T>(schema): (data: unknown) => data is T`
322
+
323
+ Creates a synchronous type guard function from a Zod schema.
324
+
325
+ ```typescript
326
+ const isUser = createValidator(userSchema);
327
+
328
+ if (isUser(unknownData)) {
329
+ // unknownData is narrowed to User type
330
+ }
331
+ ```
332
+
333
+ #### `createAsyncValidator<T>(schema): (data: unknown) => Promise<boolean>`
334
+
335
+ Creates an async validator function. Returns `true`/`false` without throwing.
336
+
337
+ ```typescript
338
+ const isValidAsync = createAsyncValidator(asyncSchema);
339
+ const valid = await isValidAsync(data);
340
+ ```
341
+
342
+ #### `createValidationDecorator<T>(schema, options?): MethodDecorator`
343
+
344
+ Creates a method decorator that validates the first argument before calling the method. Replaces the first argument with the validated/parsed data. Throws on validation failure.
345
+
346
+ ```typescript
347
+ class UserService {
348
+ @createValidationDecorator(createUserSchema)
349
+ async createUser(data: CreateUserInput) {
350
+ // data is guaranteed valid
351
+ }
352
+ }
353
+ ```
354
+
355
+ #### `parseAndValidateJSON<T>(schema, jsonString): T`
356
+
357
+ Combines `JSON.parse()` with Zod validation. Throws `SyntaxError` for invalid JSON, `ZodError` for invalid data.
358
+
359
+ ```typescript
360
+ const user = parseAndValidateJSON(
361
+ userSchema,
362
+ '{"email":"a@b.com","name":"Jo"}'
363
+ );
364
+ ```
365
+
366
+ #### `safeParseAndValidateJSON<T>(schema, jsonString): ValidationResult<T>`
367
+
368
+ Safe version of `parseAndValidateJSON` that returns a `ValidationResult` instead of throwing. Distinguishes JSON parse errors (code `'invalid_json'`) from Zod validation errors.
369
+
370
+ ```typescript
371
+ const result = safeParseAndValidateJSON(userSchema, rawJson);
372
+ if (result.success) {
373
+ console.log(result.data);
374
+ } else {
375
+ // result.errors[0].code might be 'invalid_json' or a Zod error code
376
+ }
377
+ ```
378
+
379
+ #### `formatValidationErrors(errors): Record<string, string[]>`
380
+
381
+ Groups validation errors by field name into a field-to-messages map. Suitable for API error responses.
382
+
383
+ ```typescript
384
+ // Input: [{ field: 'email', message: 'Invalid', code: 'invalid_string' }]
385
+ // Output: { email: ['Invalid'] }
386
+ ```
387
+
388
+ #### `hasErrorCode(result, code): boolean`
389
+
390
+ Checks if a failed `ValidationResult` contains a specific error code.
391
+
392
+ ```typescript
393
+ if (hasErrorCode(result, 'too_small')) {
394
+ // Handle minimum length violation
395
+ }
396
+ ```
397
+
398
+ #### `getFieldErrors(result, field): ValidationError[]`
399
+
400
+ Extracts all errors for a specific field from a `ValidationResult`.
401
+
402
+ ```typescript
403
+ const emailErrors = getFieldErrors(result, 'email');
404
+ ```
405
+
406
+ ### sanitization.ts
407
+
408
+ #### Constants
409
+
410
+ ```typescript
411
+ // XSS detection patterns
412
+ const XSS_PATTERNS = {
413
+ scriptTags: /<script[^>]*>.*?<\/script>/gi,
414
+ iframeTags: /<iframe[^>]*>.*?<\/iframe>/gi,
415
+ javascriptProtocol: /javascript:/gi,
416
+ vbscriptProtocol: /vbscript:/gi,
417
+ eventHandlers: /on\w+=/gi,
418
+ };
419
+
420
+ // SQL injection patterns
421
+ const SQL_PATTERNS = {
422
+ specialChars: /['";\\]/g,
423
+ keywords: /\b(union|select|insert|update|delete|drop|exec|execute)\b/gi,
424
+ };
425
+
426
+ // HTML entity encoding map
427
+ const HTML_ENTITIES: Record<string, string> = {
428
+ '&': '&amp;',
429
+ '<': '&lt;',
430
+ '>': '&gt;',
431
+ '"': '&quot;',
432
+ "'": '&#x27;',
433
+ };
434
+ ```
435
+
436
+ #### `containsXssPatterns(value): boolean`
437
+
438
+ Detection-only check. Returns `true` if string contains `<script` tags, event handlers (`on*=`), or `javascript:` protocol. Does not modify the string.
439
+
440
+ ```typescript
441
+ if (containsXssPatterns(userInput)) {
442
+ // Reject input
443
+ }
444
+ ```
445
+
446
+ #### `sanitizeString(input, options?): string`
447
+
448
+ Applies sanitization transforms in order:
449
+
450
+ 1. **Trim** (default on, disable with `trim: false`)
451
+ 2. **XSS removal** (default on, disable with `xss: false`) - strips script/iframe tags, javascript:/vbscript: protocols, event handlers
452
+ 3. **SQL removal** (default on, disable with `sql: false`) - strips quotes, semicolons, backslashes, SQL keywords
453
+ 4. **HTML encoding** (default off, enable with `html: true`) - encodes `& < > " '` to entities
454
+ 5. **Case transform** - `lowercase: true` or `uppercase: true` (mutually exclusive, lowercase checked first)
455
+
456
+ ```typescript
457
+ const clean = sanitizeString('<script>alert("xss")</script>Hello', {
458
+ xss: true,
459
+ sql: true,
460
+ html: false,
461
+ trim: true,
462
+ });
463
+ // Result: "Hello"
464
+ ```
465
+
466
+ #### `sanitizeObjectStrings(obj): unknown`
467
+
468
+ Recursively walks an object/array structure and applies `sanitizeString()` with `{ xss: true, sql: true, trim: true }` to every string value. Non-string/non-object values pass through unchanged.
469
+
470
+ ```typescript
471
+ const safe = sanitizeObjectStrings({
472
+ name: ' <script>xss</script>John ',
473
+ tags: ['hello', '<iframe>bad</iframe>'],
474
+ count: 42,
475
+ });
476
+ // Result: { name: 'John', tags: ['hello', ''], count: 42 }
477
+ ```
478
+
479
+ ### schemas.ts
480
+
481
+ All schemas are exported as named object collections.
482
+
483
+ #### `primitiveSchemas`
484
+
485
+ | Key | Schema | Description |
486
+ | ---------------- | -------------------------------------- | ------------------------ |
487
+ | `nonEmptyString` | `z.string().min(1)` | Non-empty string |
488
+ | `email` | `z.string().email()` | Email format |
489
+ | `url` | `z.string().url()` | URL format |
490
+ | `uuid` | `z.string().uuid()` | UUID format |
491
+ | `positiveInt` | `z.number().int().positive()` | Positive integer |
492
+ | `nonNegativeInt` | `z.number().int().min(0)` | Non-negative integer |
493
+ | `percentage` | `z.number().min(0).max(100)` | 0-100 range |
494
+ | `isoDate` | `z.string().datetime()` | ISO 8601 datetime string |
495
+ | `futureDate` | `z.date().refine(d => d > new Date())` | Date in the future |
496
+ | `booleanString` | `z.enum(['true','false']).transform()` | String to boolean |
497
+
498
+ #### `securitySchemas`
499
+
500
+ | Key | Description |
501
+ | ---------------- | --------------------------------------------------------------------------------------------- |
502
+ | `safeString` | Rejects strings with `<script`, `javascript:`, `data:`, `vbscript:`, or `on*=` event handlers |
503
+ | `safeFilename` | Rejects `<>:"/\|?*` characters and `..` path traversal |
504
+ | `sqlSafeString` | Rejects SQL keywords (union, select, insert, update, delete, drop, exec, execute) |
505
+ | `strongPassword` | Min 8 chars, requires uppercase + lowercase + digit + special character |
506
+
507
+ #### `apiSchemas`
508
+
509
+ | Key | Description |
510
+ | ------------------------- | ----------------------------------------------------------------------------------------------------- |
511
+ | `pagination` | `{ page, limit, sortBy?, sortOrder }` with coercion and defaults (page=1, limit=20, sortOrder='desc') |
512
+ | `apiResponse(dataSchema)` | Generic wrapper: `{ success, data?, error?, timestamp, requestId }` |
513
+ | `errorResponse` | `{ success: false, error, details?: [{ field, message, code }], timestamp, requestId }` |
514
+
515
+ #### `authSchemas`
516
+
517
+ | Key | Description |
518
+ | ------------------ | ----------------------------------------------------------------------------- |
519
+ | `userRegistration` | `{ email, password(strong), firstName, lastName, acceptTerms(must be true) }` |
520
+ | `userLogin` | `{ email, password, rememberMe? }` |
521
+ | `jwtPayload` | `{ sub(uuid), email, roles[], permissions[], iat, exp }` |
522
+
523
+ #### `fractalSchemas`
524
+
525
+ | Key | Description |
526
+ | ------------------ | --------------------------------------------------------------------------------------------------------- |
527
+ | `exportConfig` | `{ interfaces(api/cli/sdk/webhook), method?, path?, description, version?, authentication?, rateLimit? }` |
528
+ | `testConfig` | `{ categories[], priority, timeout?, retries, parallel }` |
529
+ | `validationConfig` | `{ rules[], securityLevel, sanitize }` |
530
+ | `cacheConfig` | `{ ttl, key, strategy, invalidateOn?, compress }` |
531
+ | `authConfig` | `{ roles[], permissions?, requireAll, allowAnonymous, sessionRequired }` |
532
+
533
+ #### `envSchemas`
534
+
535
+ | Key | Description |
536
+ | ------------- | -------------------------------------------------------------------------------------------------------- |
537
+ | `development` | `NODE_ENV='development'`, optional DATABASE_URL/API_KEY, DEBUG defaults true |
538
+ | `production` | `NODE_ENV='production'`, required DATABASE_URL, API_KEY(min 32), JWT_SECRET(min 32), DEBUG must be false |
539
+
540
+ #### `fileSchemas`
541
+
542
+ | Key | Description |
543
+ | ---------------- | ------------------------------------------------------------------------ |
544
+ | `imageUpload` | `{ filename(safe), mimetype(jpeg/png/gif/webp), size(max 5MB), buffer }` |
545
+ | `documentUpload` | `{ filename(safe), mimetype(pdf/text/json), size(max 10MB), buffer }` |
546
+
547
+ ### middleware.ts
548
+
549
+ #### `createValidationMiddleware(config: EndpointValidation)`
550
+
551
+ Returns an async function `(request, context?, params?) => NextResponse | { validatedData }`.
552
+
553
+ Two validation modes:
554
+
555
+ 1. **Unified input** (`config.input`): Merges query params + URL params + body into one object, validates against `config.input`. Still validates `config.headers` separately.
556
+ 2. **Legacy** (`config.body`/`config.query`/`config.headers`): Validates each part independently.
557
+
558
+ On failure: returns `NextResponse` with status 400, `{ success: false, error, details, timestamp }`.
559
+ On success: returns `{ validatedData: { body?, query?, headers?, input? } }`.
560
+
561
+ ```typescript
562
+ const middleware = createValidationMiddleware({
563
+ input: z.object({ title: z.string(), category: z.string() }),
564
+ });
565
+
566
+ const result = await middleware(request, undefined, { id: '123' });
567
+ ```
568
+
569
+ #### `validateRoute(config: EndpointValidation)`
570
+
571
+ Method decorator. Wraps a route handler method to run validation before execution. Extracts route params from the Next.js context argument automatically.
572
+
573
+ ```typescript
574
+ class EventsController {
575
+ @validateRoute({ input: CreateEventSchema })
576
+ async POST(request: NextRequest, validatedData: Record<string, unknown>) {
577
+ // validatedData.input contains validated merged input
578
+ }
579
+ }
580
+ ```
581
+
582
+ #### `withValidation(config, handler)`
583
+
584
+ Higher-order function wrapping a route handler with validation. Extracts route params from the context argument.
585
+
586
+ ```typescript
587
+ export const POST = withValidation(
588
+ { body: createUserSchema },
589
+ async (request, validatedData, context) => {
590
+ const user = await createUser(validatedData['body']);
591
+ return NextResponse.json(user);
592
+ }
593
+ );
594
+ ```
595
+
596
+ #### `createSecurityMiddleware(securityLevel?): (request) => NextResponse | null`
597
+
598
+ Returns synchronous middleware. Returns `null` to pass, or a 403 `NextResponse` on failure.
599
+
600
+ | Level | Checks |
601
+ | ---------- | --------------------------------------------------------------------------------------------------------- |
602
+ | `LOW` | Always passes |
603
+ | `MEDIUM` | Blocks bot/crawler/spider user agents |
604
+ | `HIGH` | Blocks bot/crawler/spider/curl/wget + requires `Origin` header |
605
+ | `CRITICAL` | Blocks bot/crawler/spider/curl/wget/python/ruby/php + requires `Origin` + requires `Authorization` header |
606
+
607
+ ```typescript
608
+ const security = createSecurityMiddleware(SecurityLevel.HIGH);
609
+ const result = security(request);
610
+ if (result) return result; // 403 response
611
+ ```
612
+
613
+ #### `createRateLimitMiddleware(requests?, windowMs?)`
614
+
615
+ IP-based rate limiting using `MemoryRateLimitStore`. Defaults: 100 requests per 60000ms. Extracts IP from `x-forwarded-for` or `x-real-ip` headers, falls back to `127.0.0.1`.
616
+
617
+ Returns `null` to pass, or a 429 `NextResponse` with rate limit headers:
618
+
619
+ - `X-RateLimit-Limit`
620
+ - `X-RateLimit-Remaining`
621
+ - `X-RateLimit-Reset`
622
+ - `Retry-After`
623
+
624
+ ```typescript
625
+ const rateLimit = createRateLimitMiddleware(50, 30000); // 50 req / 30s
626
+ const result = rateLimit(request);
627
+ if (result) return result; // 429 response
628
+ ```
629
+
630
+ #### `combineMiddleware(...middlewares)`
631
+
632
+ Composes multiple synchronous middleware functions. Runs each in order; returns the first non-null response (short-circuits).
633
+
634
+ ```typescript
635
+ const combined = combineMiddleware(
636
+ createSecurityMiddleware(SecurityLevel.MEDIUM),
637
+ createRateLimitMiddleware(100, 60000)
638
+ );
639
+ const result = combined(request);
640
+ if (result) return result;
641
+ ```
642
+
643
+ #### `createApiMiddleware(options)`
644
+
645
+ Comprehensive middleware stack combining security, rate limiting, and validation. Returns an async function `(request, handler, context?) => NextResponse`.
646
+
647
+ ```typescript
648
+ const apiMiddleware = createApiMiddleware({
649
+ security: SecurityLevel.HIGH,
650
+ rateLimit: { requests: 100, windowMs: 60000 },
651
+ validation: { body: createEventSchema },
652
+ });
653
+
654
+ return apiMiddleware(request, async (req, validatedData) => {
655
+ return NextResponse.json({ success: true });
656
+ });
657
+ ```
658
+
659
+ #### `safeExtractValidatedData(result): Record<string, unknown>`
660
+
661
+ Extracts `validatedData` from a middleware result object. Returns empty object if not present.
662
+
663
+ ### env.ts
664
+
665
+ #### `validateEnvironment()`
666
+
667
+ Validates `process.env` against the appropriate Zod schema based on `NODE_ENV`:
668
+
669
+ - `'production'` -> `productionEnvSchema` (strict: DATABASE_URL required, JWT_SECRET min 32, DEBUG must be false)
670
+ - `'development'` -> `developmentEnvSchema` (relaxed: optional fields, DEBUG defaults true)
671
+ - `'test'` or other -> `baseEnvSchema`
672
+
673
+ Also runs `scanEnvironmentSecurity()` and fails if security errors are found.
674
+
675
+ Returns: `{ success, data?, errors?, securityScan }`
676
+
677
+ #### `scanEnvironmentSecurity()`
678
+
679
+ Scans all `process.env` entries for security issues:
680
+
681
+ - **Production requirements**: checks DATABASE_URL and JWT_SECRET exist in production
682
+ - **Weak secrets**: detects variables with names matching `/secret|password|pass|pwd|key|token|auth|api|private|credential/i` that have weak values (test, dev, default, 123456, admin, root) or short length (<16 chars)
683
+ - **Recommendations**: always-present general security advice
684
+
685
+ Returns: `{ warnings: string[], errors: string[], recommendations: string[] }`
686
+
687
+ #### `logEnvironmentValidation(result)`
688
+
689
+ Logs validation results using the structured logger:
690
+
691
+ - Success/failure status
692
+ - Security errors (error level)
693
+ - Security warnings (warn level)
694
+ - Recommendations (info level)
695
+
696
+ #### `initializeEnvironment(): boolean`
697
+
698
+ Runs `validateEnvironment()` and `logEnvironmentValidation()`. Calls `process.exit(1)` if validation fails. Returns `true` on success.
699
+
700
+ #### `processValidationResult(validatedEnv, securityScan)`
701
+
702
+ Helper that combines Zod validation result with security scan. Fails if security scan has errors.
703
+
704
+ #### `processValidationError(error, securityScan)`
705
+
706
+ Helper that formats validation errors into the standard result structure.
707
+
708
+ ### zod-schema-converter.ts
709
+
710
+ #### `zodToJsonSchema(schema): JsonSchema`
711
+
712
+ Converts a Zod schema to JSON Schema using Zod 4's native `toJSONSchema()` method. Throws if the schema doesn't support this method.
713
+
714
+ ```typescript
715
+ const userSchema = z.object({ name: z.string(), age: z.number() });
716
+ const jsonSchema = zodToJsonSchema(userSchema);
717
+ // { type: 'object', properties: { name: { type: 'string' }, age: { type: 'number' } }, ... }
718
+ ```
719
+
720
+ #### `safeZodToJsonSchema(schema?): JsonSchema | undefined`
721
+
722
+ Safe wrapper around `zodToJsonSchema`. Returns `undefined` if schema is undefined or conversion fails.
723
+
724
+ #### `extractMethodSchemas(inputSchema?, outputSchema?): MethodSchemas`
725
+
726
+ Convenience function that converts both input and output Zod schemas to JSON Schema.
727
+
728
+ ```typescript
729
+ interface MethodSchemas {
730
+ input?: JsonSchema | undefined;
731
+ output?: JsonSchema | undefined;
732
+ }
733
+ ```
734
+
735
+ #### `isZodSchemaWithJsonSupport(value): value is ZodSchema`
736
+
737
+ Runtime check for whether a value is a Zod schema with `toJSONSchema()`, `parse()`, and `safeParse()` methods.
738
+
739
+ ## Error Handling Patterns
740
+
741
+ ### Discriminated Union Results
742
+
743
+ All validation functions return `ValidationResult<T>`, a discriminated union. Always check `result.success` before accessing `.data` or `.errors`.
744
+
745
+ ```typescript
746
+ const result = await validateData(schema, data);
747
+ if (result.success) {
748
+ // TypeScript narrows: result.data is T
749
+ processData(result.data);
750
+ } else {
751
+ // TypeScript narrows: result.errors is ValidationError[]
752
+ const formatted = formatValidationErrors(result.errors);
753
+ return NextResponse.json(
754
+ { success: false, details: formatted },
755
+ { status: 400 }
756
+ );
757
+ }
758
+ ```
759
+
760
+ ### Middleware Error Responses
761
+
762
+ Validation middleware returns structured JSON error responses:
763
+
764
+ ```json
765
+ {
766
+ "success": false,
767
+ "error": "Validation failed",
768
+ "details": {
769
+ "email": ["Invalid email format"],
770
+ "age": ["Must be a positive integer"]
771
+ },
772
+ "timestamp": "2025-01-29T12:00:00.000Z"
773
+ }
774
+ ```
775
+
776
+ Security middleware returns:
777
+
778
+ ```json
779
+ {
780
+ "success": false,
781
+ "error": "Security check failed",
782
+ "timestamp": "2025-01-29T12:00:00.000Z"
783
+ }
784
+ ```
785
+
786
+ Rate limit middleware returns (with headers):
787
+
788
+ ```json
789
+ {
790
+ "success": false,
791
+ "error": "Rate limit exceeded",
792
+ "retryAfter": 45,
793
+ "timestamp": "2025-01-29T12:00:00.000Z"
794
+ }
795
+ ```
796
+
797
+ ### Environment Validation Errors
798
+
799
+ `validateEnvironment()` returns a structured result. `initializeEnvironment()` exits the process on failure, so callers never see the error.
800
+
801
+ ```typescript
802
+ const result = validateEnvironment();
803
+ if (!result.success) {
804
+ // result.errors: string[] - human-readable error messages
805
+ // result.securityScan.warnings: string[] - security warnings
806
+ // result.securityScan.recommendations: string[] - improvement suggestions
807
+ }
808
+ ```
809
+
810
+ ## Use Cases
811
+
812
+ ### 1. Validate API Request Body
813
+
814
+ ```typescript
815
+ import { validateData } from '@scallywag/kernel/validation';
816
+ import { z } from 'zod';
817
+
818
+ const CreateEventSchema = z.object({
819
+ title: z.string().min(1).max(200),
820
+ date: z.string().datetime(),
821
+ capacity: z.number().int().positive().max(10000),
822
+ });
823
+
824
+ async function handleRequest(body: unknown) {
825
+ const result = await validateData(CreateEventSchema, body);
826
+ if (!result.success) {
827
+ return { status: 400, errors: result.errors };
828
+ }
829
+ return createEvent(result.data);
830
+ }
831
+ ```
832
+
833
+ ### 2. Sanitize User Input Before Storage
834
+
835
+ ```typescript
836
+ import {
837
+ sanitizeString,
838
+ sanitizeObjectStrings,
839
+ } from '@scallywag/kernel/validation';
840
+
841
+ // Single field
842
+ const safeName = sanitizeString(rawName, { xss: true, sql: true, trim: true });
843
+
844
+ // Entire request body
845
+ const safeBody = sanitizeObjectStrings(requestBody);
846
+ ```
847
+
848
+ ### 3. Validate and Sanitize Together
849
+
850
+ ```typescript
851
+ import { validateRequest, SecurityLevel } from '@scallywag/kernel/validation';
852
+
853
+ const result = await validateRequest(commentSchema, requestBody, {
854
+ interface: 'api',
855
+ securityLevel: SecurityLevel.HIGH,
856
+ });
857
+ // Body is sanitized (XSS/SQL stripped) THEN validated against schema
858
+ ```
859
+
860
+ ### 4. Validate File Uploads
861
+
862
+ ```typescript
863
+ import { validateFileUpload } from '@scallywag/kernel/validation';
864
+
865
+ const result = validateFileUpload(
866
+ uploadedFile,
867
+ ['image/jpeg', 'image/png', 'image/webp'],
868
+ 5 * 1024 * 1024
869
+ );
870
+ if (!result.success) {
871
+ // result.errors may contain type AND size errors simultaneously
872
+ }
873
+ ```
874
+
875
+ ### 5. Next.js API Route with Middleware
876
+
877
+ ```typescript
878
+ import { withValidation } from '@scallywag/kernel/validation';
879
+ import { z } from 'zod';
880
+
881
+ const schema = z.object({
882
+ email: z.string().email(),
883
+ password: z.string().min(8),
884
+ });
885
+
886
+ export const POST = withValidation(
887
+ { body: schema },
888
+ async (request, validatedData) => {
889
+ const { email, password } = validatedData['body'] as {
890
+ email: string;
891
+ password: string;
892
+ };
893
+ return NextResponse.json({ success: true });
894
+ }
895
+ );
896
+ ```
897
+
898
+ ### 6. Unified Input Validation (Preferred for New Endpoints)
899
+
900
+ ```typescript
901
+ import { createValidationMiddleware } from '@scallywag/kernel/validation';
902
+ import { z } from 'zod';
903
+
904
+ const middleware = createValidationMiddleware({
905
+ input: z.object({
906
+ id: z.string().uuid(), // from URL params
907
+ title: z.string().min(1), // from body
908
+ format: z.string().optional(), // from query string
909
+ }),
910
+ });
911
+
912
+ // Middleware merges query + params + body into one object
913
+ const result = await middleware(request, undefined, { id: routeParamId });
914
+ ```
915
+
916
+ ### 7. Security Middleware
917
+
918
+ ```typescript
919
+ import {
920
+ createSecurityMiddleware,
921
+ SecurityLevel,
922
+ } from '@scallywag/kernel/validation';
923
+
924
+ const security = createSecurityMiddleware(SecurityLevel.CRITICAL);
925
+
926
+ export async function GET(request: NextRequest) {
927
+ const secResult = security(request);
928
+ if (secResult) return secResult; // 403 for bots, missing origin, missing auth
929
+ // ... handle request
930
+ }
931
+ ```
932
+
933
+ ### 8. Rate Limiting
934
+
935
+ ```typescript
936
+ import { createRateLimitMiddleware } from '@scallywag/kernel/validation';
937
+
938
+ const rateLimit = createRateLimitMiddleware(100, 60000);
939
+
940
+ export async function POST(request: NextRequest) {
941
+ const limited = rateLimit(request);
942
+ if (limited) return limited; // 429 with Retry-After
943
+ // ... handle request
944
+ }
945
+ ```
946
+
947
+ ### 9. Combined Middleware Stack
948
+
949
+ ```typescript
950
+ import {
951
+ combineMiddleware,
952
+ createSecurityMiddleware,
953
+ createRateLimitMiddleware,
954
+ SecurityLevel,
955
+ } from '@scallywag/kernel/validation';
956
+
957
+ const middleware = combineMiddleware(
958
+ createSecurityMiddleware(SecurityLevel.HIGH),
959
+ createRateLimitMiddleware(50, 30000)
960
+ );
961
+
962
+ const blocked = middleware(request);
963
+ if (blocked) return blocked;
964
+ ```
965
+
966
+ ### 10. Full API Middleware
967
+
968
+ ```typescript
969
+ import {
970
+ createApiMiddleware,
971
+ SecurityLevel,
972
+ } from '@scallywag/kernel/validation';
973
+
974
+ const api = createApiMiddleware({
975
+ security: SecurityLevel.HIGH,
976
+ rateLimit: { requests: 100, windowMs: 60000 },
977
+ validation: {
978
+ body: z.object({ name: z.string() }),
979
+ query: z.object({ page: z.coerce.number().default(1) }),
980
+ },
981
+ });
982
+
983
+ export async function POST(request: NextRequest) {
984
+ return api(request, async (req, validatedData) => {
985
+ return NextResponse.json({ data: validatedData });
986
+ });
987
+ }
988
+ ```
989
+
990
+ ### 11. Batch Validation
991
+
992
+ ```typescript
993
+ import { batchValidate } from '@scallywag/kernel/validation';
994
+
995
+ const results = await batchValidate(userSchema, [user1, user2, user3]);
996
+ const allValid = results.every((r) => r.success);
997
+ ```
998
+
999
+ ### 12. Type Guards from Schemas
1000
+
1001
+ ```typescript
1002
+ import {
1003
+ createValidator,
1004
+ createAsyncValidator,
1005
+ } from '@scallywag/kernel/validation';
1006
+
1007
+ const isUser = createValidator(userSchema);
1008
+ const items: unknown[] = [
1009
+ /* ... */
1010
+ ];
1011
+ const users = items.filter(isUser); // User[]
1012
+
1013
+ const isValidAsync = createAsyncValidator(asyncRefinedSchema);
1014
+ if (await isValidAsync(data)) {
1015
+ // data passes async validation
1016
+ }
1017
+ ```
1018
+
1019
+ ### 13. Validation Decorator on Class Methods
1020
+
1021
+ ```typescript
1022
+ import { createValidationDecorator } from '@scallywag/kernel/validation';
1023
+
1024
+ class PaymentService {
1025
+ @createValidationDecorator(paymentSchema)
1026
+ async processPayment(input: PaymentInput) {
1027
+ // input is guaranteed to be valid
1028
+ }
1029
+ }
1030
+ ```
1031
+
1032
+ ### 14. Route Decorator
1033
+
1034
+ ```typescript
1035
+ import { validateRoute } from '@scallywag/kernel/validation';
1036
+
1037
+ class EventsController {
1038
+ @validateRoute({
1039
+ input: z.object({ title: z.string(), category: z.string() }),
1040
+ headers: z.object({ 'x-api-key': z.string() }),
1041
+ })
1042
+ async POST(request: NextRequest, validatedData: Record<string, unknown>) {
1043
+ const input = validatedData['input'];
1044
+ const headers = validatedData['headers'];
1045
+ }
1046
+ }
1047
+ ```
1048
+
1049
+ ### 15. JSON String Parse + Validate
1050
+
1051
+ ```typescript
1052
+ import {
1053
+ parseAndValidateJSON,
1054
+ safeParseAndValidateJSON,
1055
+ } from '@scallywag/kernel/validation';
1056
+
1057
+ // Throwing version
1058
+ try {
1059
+ const user = parseAndValidateJSON(userSchema, jsonString);
1060
+ } catch (e) {
1061
+ // SyntaxError or ZodError
1062
+ }
1063
+
1064
+ // Safe version
1065
+ const result = safeParseAndValidateJSON(userSchema, jsonString);
1066
+ if (!result.success) {
1067
+ // Check result.errors[0].code === 'invalid_json' for parse errors
1068
+ }
1069
+ ```
1070
+
1071
+ ### 16. Environment Validation at Startup
1072
+
1073
+ ```typescript
1074
+ import { initializeEnvironment } from '@scallywag/kernel/validation/env';
1075
+
1076
+ // Exits process if environment is invalid
1077
+ initializeEnvironment();
1078
+ ```
1079
+
1080
+ ### 17. Manual Environment Validation
1081
+
1082
+ ```typescript
1083
+ import {
1084
+ validateEnvironment,
1085
+ logEnvironmentValidation,
1086
+ } from '@scallywag/kernel/validation/env';
1087
+
1088
+ const result = validateEnvironment();
1089
+ logEnvironmentValidation(result);
1090
+
1091
+ if (!result.success) {
1092
+ console.error('Env errors:', result.errors);
1093
+ console.warn('Security warnings:', result.securityScan.warnings);
1094
+ }
1095
+ ```
1096
+
1097
+ ### 18. Environment Security Scan
1098
+
1099
+ ```typescript
1100
+ import { scanEnvironmentSecurity } from '@scallywag/kernel/validation/env';
1101
+
1102
+ const scan = scanEnvironmentSecurity();
1103
+ if (scan.errors.length > 0) {
1104
+ // Missing required production variables
1105
+ }
1106
+ if (scan.warnings.length > 0) {
1107
+ // Weak secrets, short keys, etc.
1108
+ }
1109
+ ```
1110
+
1111
+ ### 19. Using Pre-built Security Schemas
1112
+
1113
+ ```typescript
1114
+ import {
1115
+ securitySchemas,
1116
+ primitiveSchemas,
1117
+ } from '@scallywag/kernel/validation';
1118
+
1119
+ const commentSchema = z.object({
1120
+ author: securitySchemas.safeString.min(1).max(100),
1121
+ body: securitySchemas.safeString.min(1).max(5000),
1122
+ email: primitiveSchemas.email,
1123
+ });
1124
+ ```
1125
+
1126
+ ### 20. Using Pre-built API Schemas
1127
+
1128
+ ```typescript
1129
+ import { apiSchemas } from '@scallywag/kernel/validation';
1130
+
1131
+ // Pagination with coercion (query strings come as strings)
1132
+ const query = apiSchemas.pagination.parse({
1133
+ page: '3', // coerced to 3
1134
+ limit: '50', // coerced to 50
1135
+ });
1136
+
1137
+ // Generic API response wrapper
1138
+ const responseSchema = apiSchemas.apiResponse(z.object({ id: z.string() }));
1139
+ ```
1140
+
1141
+ ### 21. Zod to JSON Schema Conversion
1142
+
1143
+ ```typescript
1144
+ import {
1145
+ zodToJsonSchema,
1146
+ extractMethodSchemas,
1147
+ } from '@scallywag/kernel/validation';
1148
+
1149
+ const inputSchema = z.object({ title: z.string() });
1150
+ const jsonSchema = zodToJsonSchema(inputSchema);
1151
+ // Use for OpenAPI docs, MCP tool definitions, etc.
1152
+
1153
+ const schemas = extractMethodSchemas(inputSchema, outputSchema);
1154
+ // { input: JsonSchema, output: JsonSchema }
1155
+ ```
1156
+
1157
+ ### 22. Error Inspection Utilities
1158
+
1159
+ ```typescript
1160
+ import {
1161
+ hasErrorCode,
1162
+ getFieldErrors,
1163
+ formatValidationErrors,
1164
+ } from '@scallywag/kernel/validation';
1165
+
1166
+ const result = await validateData(schema, data);
1167
+ if (!result.success) {
1168
+ if (hasErrorCode(result, 'too_small')) {
1169
+ // Handle minimum constraint violation
1170
+ }
1171
+
1172
+ const emailErrors = getFieldErrors(result, 'email');
1173
+ // All errors specifically for the 'email' field
1174
+
1175
+ const formatted = formatValidationErrors(result.errors);
1176
+ // { email: ['Invalid email format'], name: ['Required'] }
1177
+ }
1178
+ ```
1179
+
1180
+ ### 23. Using File Schemas
1181
+
1182
+ ```typescript
1183
+ import { fileSchemas } from '@scallywag/kernel/validation';
1184
+
1185
+ const result = fileSchemas.imageUpload.safeParse({
1186
+ filename: 'photo.jpg',
1187
+ mimetype: 'image/jpeg',
1188
+ size: 1024000,
1189
+ buffer: fileBuffer,
1190
+ });
1191
+ ```
1192
+
1193
+ ### 24. XSS Detection Without Modification
1194
+
1195
+ ```typescript
1196
+ import { containsXssPatterns } from '@scallywag/kernel/validation';
1197
+
1198
+ // Pure detection - reject rather than sanitize
1199
+ if (containsXssPatterns(userInput)) {
1200
+ throw new Error('Input contains unsafe patterns');
1201
+ }
1202
+ ```
1203
+
1204
+ ## Testing Patterns
1205
+
1206
+ Tests are located in `packages/kernel/__tests__/core/validation/`.
1207
+
1208
+ ### Testing Validation Functions
1209
+
1210
+ ```typescript
1211
+ import { validateData } from '@scallywag/kernel/validation';
1212
+ import { z } from 'zod';
1213
+
1214
+ describe('validateData', () => {
1215
+ const schema = z.object({
1216
+ email: z.string().email(),
1217
+ age: z.number().min(18),
1218
+ });
1219
+
1220
+ it('returns success with valid data', async () => {
1221
+ const result = await validateData(schema, { email: 'a@b.com', age: 25 });
1222
+ expect(result.success).toBe(true);
1223
+ if (result.success) {
1224
+ expect(result.data.email).toBe('a@b.com');
1225
+ }
1226
+ });
1227
+
1228
+ it('returns errors with invalid data', async () => {
1229
+ const result = await validateData(schema, { email: 'bad', age: 5 });
1230
+ expect(result.success).toBe(false);
1231
+ if (!result.success) {
1232
+ expect(result.errors).toHaveLength(2);
1233
+ expect(result.errors[0]?.field).toBe('email');
1234
+ }
1235
+ });
1236
+ });
1237
+ ```
1238
+
1239
+ ### Testing Sanitization
1240
+
1241
+ ```typescript
1242
+ import {
1243
+ sanitizeString,
1244
+ containsXssPatterns,
1245
+ sanitizeObjectStrings,
1246
+ } from '@scallywag/kernel/validation';
1247
+
1248
+ describe('sanitizeString', () => {
1249
+ it('removes script tags', () => {
1250
+ expect(sanitizeString('<script>alert(1)</script>Hello')).toBe('Hello');
1251
+ });
1252
+
1253
+ it('removes SQL special chars by default', () => {
1254
+ expect(sanitizeString("Robert'; DROP TABLE users;--")).not.toContain("'");
1255
+ });
1256
+
1257
+ it('encodes HTML entities when html option enabled', () => {
1258
+ expect(
1259
+ sanitizeString('<b>bold</b>', { html: true, xss: false, sql: false })
1260
+ ).toBe('&lt;b&gt;bold&lt;/b&gt;');
1261
+ });
1262
+ });
1263
+
1264
+ describe('sanitizeObjectStrings', () => {
1265
+ it('recursively sanitizes nested objects', () => {
1266
+ const result = sanitizeObjectStrings({
1267
+ nested: { value: '<script>xss</script>safe' },
1268
+ });
1269
+ expect(
1270
+ (result as Record<string, Record<string, string>>).nested.value
1271
+ ).toBe('safe');
1272
+ });
1273
+ });
1274
+ ```
1275
+
1276
+ ### Testing Middleware
1277
+
1278
+ ```typescript
1279
+ import {
1280
+ createValidationMiddleware,
1281
+ createSecurityMiddleware,
1282
+ SecurityLevel,
1283
+ } from '@scallywag/kernel/validation';
1284
+ import { NextRequest } from 'next/server';
1285
+ import { z } from 'zod';
1286
+
1287
+ describe('createValidationMiddleware', () => {
1288
+ it('returns 400 for invalid body', async () => {
1289
+ const middleware = createValidationMiddleware({
1290
+ body: z.object({ name: z.string() }),
1291
+ });
1292
+
1293
+ const request = new NextRequest('http://localhost/api/test', {
1294
+ method: 'POST',
1295
+ body: JSON.stringify({ name: 123 }),
1296
+ });
1297
+
1298
+ const result = await middleware(request);
1299
+ expect('status' in result && result.status).toBe(400);
1300
+ });
1301
+ });
1302
+
1303
+ describe('createSecurityMiddleware', () => {
1304
+ it('blocks bot user agents at MEDIUM level', () => {
1305
+ const security = createSecurityMiddleware(SecurityLevel.MEDIUM);
1306
+ const request = new NextRequest('http://localhost/api/test', {
1307
+ headers: { 'user-agent': 'Googlebot/2.1' },
1308
+ });
1309
+ const result = security(request);
1310
+ expect(result).not.toBeNull();
1311
+ expect(result?.status).toBe(403);
1312
+ });
1313
+ });
1314
+ ```
1315
+
1316
+ ### Testing Environment Validation
1317
+
1318
+ ```typescript
1319
+ import {
1320
+ scanEnvironmentSecurity,
1321
+ validateEnvironment,
1322
+ } from '@scallywag/kernel/validation/env';
1323
+
1324
+ describe('scanEnvironmentSecurity', () => {
1325
+ const originalEnv = process.env;
1326
+
1327
+ beforeEach(() => {
1328
+ process.env = { ...originalEnv };
1329
+ });
1330
+
1331
+ afterAll(() => {
1332
+ process.env = originalEnv;
1333
+ });
1334
+
1335
+ it('reports missing required vars in production', () => {
1336
+ process.env['NODE_ENV'] = 'production';
1337
+ delete process.env['DATABASE_URL'];
1338
+ const result = scanEnvironmentSecurity();
1339
+ expect(result.errors.length).toBeGreaterThan(0);
1340
+ });
1341
+ });
1342
+ ```
1343
+
1344
+ ### Testing Zod Schema Converter
1345
+
1346
+ ```typescript
1347
+ import {
1348
+ zodToJsonSchema,
1349
+ isZodSchemaWithJsonSupport,
1350
+ } from '@scallywag/kernel/validation';
1351
+
1352
+ describe('zodToJsonSchema', () => {
1353
+ it('throws for schemas without toJSONSchema', () => {
1354
+ const fakeSchema = { parse: () => {}, safeParse: () => {} };
1355
+ expect(() => zodToJsonSchema(fakeSchema as never)).toThrow();
1356
+ });
1357
+ });
1358
+ ```
1359
+
1360
+ ## Exports
1361
+
1362
+ `index.ts` re-exports from:
1363
+
1364
+ - `./middleware` - all middleware functions, `EndpointValidation`, `safeExtractValidatedData`
1365
+ - `./schemas` - all schema collections
1366
+ - `./types` - all type definitions and enums
1367
+ - `./validators` - all validation functions and sanitization re-exports
1368
+ - `./zod-schema-converter` - all JSON Schema conversion utilities
1369
+
1370
+ Note: `./sanitization` is not directly re-exported from index but is re-exported through `./validators` (`sanitizeString`, `sanitizeObjectStrings`, `containsXssPatterns`, `XSS_PATTERNS`, `SQL_PATTERNS`, `HTML_ENTITIES`).
1371
+
1372
+ Note: `./env` is NOT re-exported from index. Import directly from `@scallywag/kernel/validation/env`.