@panguard-ai/threat-cloud 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/server.js CHANGED
@@ -15,6 +15,10 @@
15
15
  * - GET /api/feeds/ip-blocklist IP blocklist feed (text/plain, ?minReputation=)
16
16
  * - GET /api/feeds/domain-blocklist Domain blocklist feed (text/plain, ?minReputation=)
17
17
  * - GET /api/skill-blacklist Community skill blacklist (aggregated threats)
18
+ * - POST /api/analyze-skills Submit scan results for server-side LLM analysis
19
+ * - GET /api/audit-log Admin audit log (paginated, admin-only)
20
+ * - POST /api/scan-events Report scan event from any source (bulk/CLI/web)
21
+ * - GET /api/metrics Aggregated metrics across all sources (public, cached 60s)
18
22
  * - GET /health Health check
19
23
  *
20
24
  * @module @panguard-ai/threat-cloud/server
@@ -23,17 +27,25 @@ import { createServer } from 'node:http';
23
27
  import { readdirSync, readFileSync, statSync } from 'node:fs';
24
28
  import { join, basename, relative, dirname } from 'node:path';
25
29
  import { fileURLToPath } from 'node:url';
26
- import { randomUUID } from 'node:crypto';
30
+ import { randomUUID, timingSafeEqual } from 'node:crypto';
27
31
  import { ThreatCloudDB } from './database.js';
28
32
  import { LLMReviewer } from './llm-reviewer.js';
29
33
  import { getAdminHTML } from './admin-dashboard.js';
34
+ import { tryValidateInput, ThreatDataSchema, RulePublishSchema, ATRProposalSchema, ATRFeedbackSchema, SkillThreatSchema, SkillWhitelistItemSchema, } from '@panguard-ai/core';
35
+ import { z } from 'zod';
30
36
  /** Structured JSON logger for threat-cloud */
31
37
  const log = {
32
38
  info: (msg, extra) => {
33
39
  process.stdout.write(JSON.stringify({ ts: new Date().toISOString(), level: 'info', msg, ...extra }) + '\n');
34
40
  },
35
41
  error: (msg, err, extra) => {
36
- process.stderr.write(JSON.stringify({ ts: new Date().toISOString(), level: 'error', msg, error: err instanceof Error ? err.message : String(err), ...extra }) + '\n');
42
+ process.stderr.write(JSON.stringify({
43
+ ts: new Date().toISOString(),
44
+ level: 'error',
45
+ msg,
46
+ error: err instanceof Error ? err.message : String(err),
47
+ ...extra,
48
+ }) + '\n');
37
49
  },
38
50
  };
39
51
  /**
@@ -152,20 +164,38 @@ export class ThreatCloudServer {
152
164
  // Rate limiting
153
165
  if (!this.checkRateLimit(clientIP)) {
154
166
  this.sendJson(res, 429, { ok: false, error: 'Rate limit exceeded', request_id: requestId });
155
- log.info('request', { method: req.method, path: req.url, status: 429, duration_ms: Date.now() - startTime, client_ip: clientIP, request_id: requestId });
167
+ log.info('request', {
168
+ method: req.method,
169
+ path: req.url,
170
+ status: 429,
171
+ duration_ms: Date.now() - startTime,
172
+ client_ip: clientIP,
173
+ request_id: requestId,
174
+ });
156
175
  return;
157
176
  }
158
177
  // API key verification (skip for health check)
159
178
  const url = req.url ?? '/';
160
- const rawPathname = url.split('?')[0];
179
+ const rawPathname = url.split('?')[0] ?? '/';
161
180
  // API versioning: strip /v1 prefix for backward compatibility
162
- const pathname = rawPathname.startsWith('/v1/') ? rawPathname.slice(3) : rawPathname === '/v1' ? '/' : rawPathname;
181
+ const pathname = rawPathname.startsWith('/v1/')
182
+ ? rawPathname.slice(3)
183
+ : rawPathname === '/v1'
184
+ ? '/'
185
+ : rawPathname;
163
186
  if (pathname !== '/health' && this.config.apiKeyRequired) {
164
187
  const authHeader = req.headers.authorization ?? '';
165
188
  const token = authHeader.replace('Bearer ', '');
166
189
  if (!this.config.apiKeys.includes(token)) {
167
190
  this.sendJson(res, 401, { ok: false, error: 'Invalid API key', request_id: requestId });
168
- log.info('request', { method: req.method, path: rawPathname, status: 401, duration_ms: Date.now() - startTime, client_ip: clientIP, request_id: requestId });
191
+ log.info('request', {
192
+ method: req.method,
193
+ path: rawPathname,
194
+ status: 401,
195
+ duration_ms: Date.now() - startTime,
196
+ client_ip: clientIP,
197
+ request_id: requestId,
198
+ });
169
199
  return;
170
200
  }
171
201
  }
@@ -181,7 +211,14 @@ export class ThreatCloudServer {
181
211
  if (req.method === 'OPTIONS') {
182
212
  res.writeHead(204);
183
213
  res.end();
184
- log.info('request', { method: 'OPTIONS', path: rawPathname, status: 204, duration_ms: Date.now() - startTime, client_ip: clientIP, request_id: requestId });
214
+ log.info('request', {
215
+ method: 'OPTIONS',
216
+ path: rawPathname,
217
+ status: 204,
218
+ duration_ms: Date.now() - startTime,
219
+ client_ip: clientIP,
220
+ request_id: requestId,
221
+ });
185
222
  return;
186
223
  }
187
224
  // Store requestId on response for sendJson to include
@@ -327,6 +364,14 @@ export class ThreatCloudServer {
327
364
  this.sendJson(res, 405, { ok: false, error: 'Method not allowed' });
328
365
  }
329
366
  break;
367
+ case '/api/analyze-skills':
368
+ if (req.method === 'POST') {
369
+ await this.handleAnalyzeSkills(req, res);
370
+ }
371
+ else {
372
+ this.sendJson(res, 405, { ok: false, error: 'Method not allowed' });
373
+ }
374
+ break;
330
375
  case '/api/audit-log':
331
376
  if (req.method === 'GET') {
332
377
  if (!this.checkAdminAuth(req)) {
@@ -339,6 +384,22 @@ export class ThreatCloudServer {
339
384
  this.sendJson(res, 405, { ok: false, error: 'Method not allowed' });
340
385
  }
341
386
  break;
387
+ case '/api/scan-events':
388
+ if (req.method === 'POST') {
389
+ await this.handlePostScanEvent(req, res);
390
+ }
391
+ else {
392
+ this.sendJson(res, 405, { ok: false, error: 'Method not allowed' });
393
+ }
394
+ break;
395
+ case '/api/metrics':
396
+ if (req.method === 'GET') {
397
+ this.handleGetMetrics(res);
398
+ }
399
+ else {
400
+ this.sendJson(res, 405, { ok: false, error: 'Method not allowed' });
401
+ }
402
+ break;
342
403
  default:
343
404
  this.sendJson(res, 404, { ok: false, error: 'Not found' });
344
405
  }
@@ -360,34 +421,36 @@ export class ThreatCloudServer {
360
421
  /** POST /api/threats - Upload anonymized threat data (single or batch) */
361
422
  async handlePostThreat(req, res) {
362
423
  const body = await this.readBody(req);
363
- const parsed = JSON.parse(body);
424
+ let raw;
425
+ try {
426
+ raw = JSON.parse(body);
427
+ }
428
+ catch {
429
+ this.sendJson(res, 400, { ok: false, error: 'Invalid JSON body' });
430
+ return;
431
+ }
364
432
  // Support both single object and batch { events: [...] } format
365
- const events = 'events' in parsed && Array.isArray(parsed.events)
366
- ? parsed.events
367
- : [parsed];
368
- for (const data of events) {
369
- // Validate required fields
370
- if (!data.attackSourceIP ||
371
- !data.attackType ||
372
- !data.mitreTechnique ||
373
- !data.sigmaRuleMatched ||
374
- !data.timestamp ||
375
- !data.region) {
376
- this.sendJson(res, 400, {
377
- ok: false,
378
- error: 'Missing required fields: attackSourceIP, attackType, mitreTechnique, sigmaRuleMatched, timestamp, region',
379
- });
433
+ const rawObj = raw;
434
+ const rawEvents = 'events' in rawObj && Array.isArray(rawObj['events']) ? rawObj['events'] : [raw];
435
+ const validated = [];
436
+ for (const event of rawEvents) {
437
+ const result = tryValidateInput(ThreatDataSchema, event);
438
+ if (!result.ok) {
439
+ this.sendJson(res, 400, { ok: false, error: result.error });
380
440
  return;
381
441
  }
382
- // Anonymize IP further (zero last octet if not already)
383
- data.attackSourceIP = this.anonymizeIP(data.attackSourceIP);
442
+ const mutable = { ...result.data };
443
+ mutable.attackSourceIP = this.anonymizeIP(mutable.attackSourceIP);
444
+ validated.push(mutable);
445
+ }
446
+ for (const data of validated) {
384
447
  this.db.insertThreat(data);
385
448
  }
386
449
  const clientIP = req.socket.remoteAddress ?? 'unknown';
387
- this.db.audit.logAction('client', 'threat.submit', 'threat', undefined, { count: events.length }, clientIP);
450
+ this.db.audit.logAction('client', 'threat.submit', 'threat', undefined, { count: validated.length }, clientIP);
388
451
  this.sendJson(res, 201, {
389
452
  ok: true,
390
- data: { message: 'Threat data received', count: events.length },
453
+ data: { message: 'Threat data received', count: validated.length },
391
454
  });
392
455
  }
393
456
  /** GET /api/rules?since=<ISO>&category=<cat>&severity=<sev>&source=<src> */
@@ -410,16 +473,28 @@ export class ThreatCloudServer {
410
473
  /** POST /api/rules - Publish rules (single or batch) */
411
474
  async handlePostRule(req, res) {
412
475
  const body = await this.readBody(req);
413
- const parsed = JSON.parse(body);
476
+ let raw;
477
+ try {
478
+ raw = JSON.parse(body);
479
+ }
480
+ catch {
481
+ this.sendJson(res, 400, { ok: false, error: 'Invalid JSON body' });
482
+ return;
483
+ }
414
484
  // Support both single object and batch { rules: [...] } format
415
- const rules = 'rules' in parsed && Array.isArray(parsed.rules) ? parsed.rules : [parsed];
485
+ const rawObj = raw;
486
+ const rawRules = 'rules' in rawObj && Array.isArray(rawObj['rules']) ? rawObj['rules'] : [raw];
416
487
  const now = new Date().toISOString();
417
488
  let count = 0;
418
- for (const rule of rules) {
419
- if (!rule.ruleId || !rule.ruleContent || !rule.source)
489
+ for (const rawRule of rawRules) {
490
+ const result = tryValidateInput(RulePublishSchema, rawRule);
491
+ if (!result.ok)
420
492
  continue;
421
- rule.publishedAt = rule.publishedAt || now;
422
- this.db.upsertRule(rule);
493
+ const ruleData = {
494
+ ...result.data,
495
+ publishedAt: result.data.publishedAt || now,
496
+ };
497
+ this.db.upsertRule(ruleData);
423
498
  count++;
424
499
  }
425
500
  const clientIP = req.socket.remoteAddress ?? 'unknown';
@@ -468,82 +543,57 @@ export class ThreatCloudServer {
468
543
  }
469
544
  /** POST /api/atr-proposals - Submit or confirm an ATR rule proposal */
470
545
  async handlePostATRProposal(req, res) {
471
- const body = await this.readBody(req);
472
- const data = JSON.parse(body);
473
- const clientId = req.headers['x-panguard-client-id'] ?? undefined;
474
- if (!data.patternHash || !data.ruleContent) {
475
- this.sendJson(res, 400, {
476
- ok: false,
477
- error: 'Missing required fields: patternHash, ruleContent',
478
- });
546
+ const data = await this.parseAndValidate(req, res, ATRProposalSchema);
547
+ if (!data)
479
548
  return;
480
- }
549
+ const clientId = req.headers['x-panguard-client-id'] ?? undefined;
481
550
  // Check if a proposal with the same patternHash already exists
482
551
  const proposals = this.db.getATRProposals();
483
552
  const existing = proposals.find((p) => p['pattern_hash'] === data.patternHash);
553
+ const { patternHash, ruleContent } = data;
484
554
  if (existing) {
485
- this.db.confirmATRProposal(data.patternHash);
555
+ this.db.confirmATRProposal(patternHash);
486
556
  this.sendJson(res, 200, {
487
557
  ok: true,
488
- data: { message: 'Proposal confirmed', patternHash: data.patternHash },
558
+ data: { message: 'Proposal confirmed', patternHash },
489
559
  });
490
560
  }
491
561
  else {
492
562
  const proposal = {
493
563
  ...data,
494
- clientId: clientId ?? data.clientId,
564
+ clientId,
495
565
  };
496
566
  this.db.insertATRProposal(proposal);
497
567
  // Fire-and-forget LLM review on first submission only
498
568
  if (this.llmReviewer?.isAvailable()) {
499
- void this.llmReviewer.reviewProposal(data.patternHash, data.ruleContent).catch((err) => {
500
- log.error(`LLM review failed for ${data.patternHash}`, err);
569
+ void this.llmReviewer.reviewProposal(patternHash, ruleContent).catch((err) => {
570
+ log.error(`LLM review failed for ${patternHash}`, err);
501
571
  });
502
572
  }
503
573
  this.sendJson(res, 201, {
504
574
  ok: true,
505
- data: { message: 'Proposal submitted', patternHash: data.patternHash },
575
+ data: { message: 'Proposal submitted', patternHash },
506
576
  });
507
577
  }
508
578
  }
509
579
  /** POST /api/atr-feedback - Submit feedback on an ATR rule */
510
580
  async handlePostATRFeedback(req, res) {
511
- const body = await this.readBody(req);
512
- const data = JSON.parse(body);
513
- const clientId = req.headers['x-panguard-client-id'] ?? undefined;
514
- if (!data.ruleId || typeof data.isTruePositive !== 'boolean') {
515
- this.sendJson(res, 400, {
516
- ok: false,
517
- error: 'Missing required fields: ruleId (string), isTruePositive (boolean)',
518
- });
581
+ const data = await this.parseAndValidate(req, res, ATRFeedbackSchema);
582
+ if (!data)
519
583
  return;
520
- }
584
+ const clientId = req.headers['x-panguard-client-id'] ?? undefined;
521
585
  this.db.insertATRFeedback(data.ruleId, data.isTruePositive, clientId);
522
586
  this.sendJson(res, 201, { ok: true, data: { message: 'Feedback received' } });
523
587
  }
524
588
  /** POST /api/skill-threats - Submit skill threat from audit */
525
589
  async handlePostSkillThreat(req, res) {
526
- const body = await this.readBody(req);
527
- const data = JSON.parse(body);
528
- const clientId = req.headers['x-panguard-client-id'] ?? undefined;
529
- if (!data.skillHash || !data.skillName) {
530
- this.sendJson(res, 400, {
531
- ok: false,
532
- error: 'Missing required fields: skillHash, skillName',
533
- });
534
- return;
535
- }
536
- if (typeof data.riskScore !== 'number' || data.riskScore < 0 || data.riskScore > 100) {
537
- this.sendJson(res, 400, { ok: false, error: 'riskScore must be a number between 0 and 100' });
590
+ const data = await this.parseAndValidate(req, res, SkillThreatSchema);
591
+ if (!data)
538
592
  return;
539
- }
540
- if (!data.riskLevel || typeof data.riskLevel !== 'string') {
541
- this.sendJson(res, 400, { ok: false, error: 'riskLevel is required and must be a string' });
542
- return;
543
- }
593
+ const clientId = req.headers['x-panguard-client-id'] ?? undefined;
544
594
  const submission = {
545
595
  ...data,
546
- clientId: clientId ?? data.clientId,
596
+ clientId,
547
597
  };
548
598
  this.db.insertSkillThreat(submission);
549
599
  const clientIP = req.socket.remoteAddress ?? 'unknown';
@@ -591,15 +641,24 @@ export class ThreatCloudServer {
591
641
  /** POST /api/skill-whitelist - Report a safe skill (audit passed) */
592
642
  async handlePostSkillWhitelist(req, res) {
593
643
  const body = await this.readBody(req);
594
- const data = JSON.parse(body);
595
- const skills = 'skills' in data && Array.isArray(data.skills)
596
- ? data.skills
597
- : [data];
644
+ let raw;
645
+ try {
646
+ raw = JSON.parse(body);
647
+ }
648
+ catch {
649
+ this.sendJson(res, 400, { ok: false, error: 'Invalid JSON body' });
650
+ return;
651
+ }
652
+ const rawObj = raw;
653
+ const skills = 'skills' in rawObj && Array.isArray(rawObj['skills'])
654
+ ? rawObj['skills']
655
+ : [raw];
598
656
  let count = 0;
599
657
  for (const skill of skills) {
600
- if (!skill.skillName || typeof skill.skillName !== 'string')
658
+ const result = SkillWhitelistItemSchema.safeParse(skill);
659
+ if (!result.success)
601
660
  continue;
602
- this.db.reportSafeSkill(skill.skillName, skill.fingerprintHash);
661
+ this.db.reportSafeSkill(result.data.skillName, result.data.fingerprintHash);
603
662
  count++;
604
663
  }
605
664
  this.sendJson(res, 201, { ok: true, data: { message: `${count} skill(s) reported`, count } });
@@ -622,6 +681,59 @@ export class ThreatCloudServer {
622
681
  const blacklist = this.db.getSkillBlacklist(minReports, minAvgRisk);
623
682
  this.sendJson(res, 200, { ok: true, data: blacklist });
624
683
  }
684
+ /**
685
+ * POST /api/analyze-skills - Submit scan results for server-side LLM analysis
686
+ * 提交掃描結果讓伺服器端 LLM 分析語義威脅並自動產出 ATR proposals
687
+ *
688
+ * Body: { skills: [{ package: string, tools: [{ name, description }] }] }
689
+ * Response: { ok: true, data: { analyzed, proposalsCreated, results } }
690
+ */
691
+ async handleAnalyzeSkills(req, res) {
692
+ if (!this.llmReviewer?.isAvailable()) {
693
+ this.sendJson(res, 503, {
694
+ ok: false,
695
+ error: 'LLM reviewer not available — ANTHROPIC_API_KEY not configured on server',
696
+ });
697
+ return;
698
+ }
699
+ const AnalyzeSkillsSchema = z.object({
700
+ skills: z
701
+ .array(z.object({
702
+ package: z.string().min(1),
703
+ tools: z.array(z.object({
704
+ name: z.string().min(1),
705
+ description: z.string(),
706
+ })),
707
+ }))
708
+ .min(1, 'skills array must not be empty')
709
+ .max(10, 'Maximum 10 skills per request'),
710
+ });
711
+ const data = await this.parseAndValidate(req, res, AnalyzeSkillsSchema);
712
+ if (!data)
713
+ return;
714
+ const skills = data.skills;
715
+ log.info(`Analyzing ${skills.length} skills with LLM`, {
716
+ packages: skills.map((s) => s.package),
717
+ });
718
+ const results = await this.llmReviewer.analyzeSkills(skills);
719
+ const proposalsCreated = results.reduce((sum, r) => sum + r.proposals.length, 0);
720
+ log.info(`LLM analysis complete: ${proposalsCreated} proposals from ${skills.length} skills`);
721
+ this.sendJson(res, 200, {
722
+ ok: true,
723
+ data: {
724
+ analyzed: results.length,
725
+ proposalsCreated,
726
+ results: results.map((r) => ({
727
+ package: r.package,
728
+ threatsFound: r.threatsFound,
729
+ proposalCount: r.proposals.length,
730
+ patternHashes: r.proposals.map((p) => p.patternHash),
731
+ status: r.status,
732
+ ...(r.errorReason ? { errorReason: r.errorReason } : {}),
733
+ })),
734
+ },
735
+ });
736
+ }
625
737
  /** GET /api/audit-log?page=1&limit=50 (admin-only) */
626
738
  handleGetAuditLog(url, res) {
627
739
  const params = new URL(url, `http://localhost:${this.config.port}`).searchParams;
@@ -636,6 +748,32 @@ export class ThreatCloudServer {
636
748
  meta: { total, page, limit, pages: Math.ceil(total / limit) },
637
749
  });
638
750
  }
751
+ /** POST /api/scan-events - Report a scan event from any source */
752
+ async handlePostScanEvent(req, res) {
753
+ const ScanEventSchema = z.object({
754
+ source: z.enum(['bulk-pipeline', 'cli-user', 'web-scanner']),
755
+ skillsScanned: z.number().int().min(0),
756
+ findingsCount: z.number().int().min(0),
757
+ confirmedMalicious: z.number().int().min(0).default(0),
758
+ highlySuspicious: z.number().int().min(0).default(0),
759
+ generalSuspicious: z.number().int().min(0).default(0),
760
+ cleanCount: z.number().int().min(0).default(0),
761
+ deviceHash: z.string().optional(),
762
+ });
763
+ const data = await this.parseAndValidate(req, res, ScanEventSchema);
764
+ if (!data)
765
+ return;
766
+ this.db.insertScanEvent(data);
767
+ const clientIP = req.socket.remoteAddress ?? 'unknown';
768
+ this.db.audit.logAction('client', 'scan_event.submit', 'scan_event', undefined, { source: data.source, skillsScanned: data.skillsScanned }, clientIP);
769
+ this.sendJson(res, 201, { ok: true, data: { message: 'Scan event recorded' } });
770
+ }
771
+ /** GET /api/metrics - Aggregated metrics across all sources (public, cached 60s) */
772
+ handleGetMetrics(res) {
773
+ res.setHeader('Cache-Control', 'public, max-age=60, s-maxage=60');
774
+ const metrics = this.db.getAggregatedMetrics();
775
+ this.sendJson(res, 200, { ok: true, data: metrics });
776
+ }
639
777
  /** Anonymize IP by zeroing last octet / 匿名化 IP */
640
778
  anonymizeIP(ip) {
641
779
  if (ip.includes('.')) {
@@ -660,7 +798,12 @@ export class ThreatCloudServer {
660
798
  const url = new URL(req.url ?? '/', `http://localhost:${this.config.port}`);
661
799
  const queryKey = url.searchParams.get('key');
662
800
  const headerKey = (req.headers.authorization ?? '').replace('Bearer ', '');
663
- if (queryKey !== this.config.adminApiKey && headerKey !== this.config.adminApiKey) {
801
+ const keyMatch = (candidate) => {
802
+ if (!candidate || candidate.length !== this.config.adminApiKey.length)
803
+ return false;
804
+ return timingSafeEqual(Buffer.from(candidate, 'utf-8'), Buffer.from(this.config.adminApiKey, 'utf-8'));
805
+ };
806
+ if (!keyMatch(queryKey) && !keyMatch(headerKey)) {
664
807
  res.writeHead(401, { 'Content-Type': 'text/plain' });
665
808
  res.end('Unauthorized: admin API key required. Use ?key=YOUR_KEY or Authorization header.');
666
809
  return;
@@ -674,11 +817,21 @@ export class ThreatCloudServer {
674
817
  }
675
818
  /** Check admin API key for write-protected endpoints / 檢查管理員 API 金鑰 */
676
819
  checkAdminAuth(req) {
677
- if (!this.config.adminApiKey)
678
- return true; // no admin key configured = open
820
+ if (!this.config.adminApiKey) {
821
+ // No admin key configured only allow from loopback addresses
822
+ const remoteAddr = req.socket.remoteAddress ?? '';
823
+ const isLoopback = remoteAddr === '127.0.0.1' || remoteAddr === '::1' || remoteAddr === '::ffff:127.0.0.1';
824
+ if (!isLoopback) {
825
+ log.info(`Admin access denied: no TC_ADMIN_API_KEY configured and request from non-loopback address`, { remoteAddr });
826
+ }
827
+ return isLoopback;
828
+ }
679
829
  const authHeader = req.headers.authorization ?? '';
680
830
  const token = authHeader.replace('Bearer ', '');
681
- return token === this.config.adminApiKey;
831
+ // Use timing-safe comparison to prevent timing attacks
832
+ if (token.length !== this.config.adminApiKey.length)
833
+ return false;
834
+ return timingSafeEqual(Buffer.from(token, 'utf-8'), Buffer.from(this.config.adminApiKey, 'utf-8'));
682
835
  }
683
836
  /** Rate limit check / 速率限制檢查 */
684
837
  checkRateLimit(ip) {
@@ -691,6 +844,27 @@ export class ThreatCloudServer {
691
844
  entry.count++;
692
845
  return entry.count <= this.config.rateLimitPerMinute;
693
846
  }
847
+ /**
848
+ * Parse JSON body and validate against a Zod schema.
849
+ * Returns validated data or null (sends 400 on failure).
850
+ */
851
+ async parseAndValidate(req, res, schema) {
852
+ const body = await this.readBody(req);
853
+ let raw;
854
+ try {
855
+ raw = JSON.parse(body);
856
+ }
857
+ catch {
858
+ this.sendJson(res, 400, { ok: false, error: 'Invalid JSON body' });
859
+ return null;
860
+ }
861
+ const result = tryValidateInput(schema, raw);
862
+ if (!result.ok) {
863
+ this.sendJson(res, 400, { ok: false, error: result.error });
864
+ return null;
865
+ }
866
+ return result.data;
867
+ }
694
868
  /** Read request body with size limit / 讀取請求主體(含大小限制) */
695
869
  readBody(req) {
696
870
  return new Promise((resolve, reject) => {