Coverage for integrations / social / discovery.py: 17.0%

800 statements  

« prev     ^ index     » next       coverage.py v7.14.0, created at 2026-05-12 04:49 +0000

1""" 

2HevolveSocial - Platform Discovery 

3Exposes .well-known/hevolve-social.json for external bots to discover HevolveSocial. 

4Separate from per-agent A2A cards - this advertises the platform itself. 

5""" 

6import json 

7import os 

8import logging 

9import time as _time 

10from flask import Blueprint, jsonify, request 

11from core.port_registry import get_port 

12 

13logger = logging.getLogger('hevolve_social') 

14 

15discovery_bp = Blueprint('social_discovery', __name__) 

16 

17# ─── Gossip Rate Limiter ─── 

18_ANNOUNCE_RATE = {} # ip -> list of timestamps 

19_RATE_LIMIT = 10 # max announcements per window per IP 

20_RATE_WINDOW = 60 # window in seconds 

21 

22 

23def _check_announce_rate(ip: str) -> bool: 

24 """Returns True if request is allowed, False if rate-limited. 

25 Prevents gossip flooding from rapid peer announcements.""" 

26 now = _time.time() 

27 times = _ANNOUNCE_RATE.get(ip, []) 

28 # Prune expired entries 

29 times = [t for t in times if now - t < _RATE_WINDOW] 

30 if len(times) >= _RATE_LIMIT: 

31 return False 

32 times.append(now) 

33 _ANNOUNCE_RATE[ip] = times 

34 return True 

35 

36_BASE_URL = os.environ.get('HEVOLVE_BASE_URL', f'http://localhost:{get_port("backend")}') 

37 

38 

39@discovery_bp.route('/.well-known/hevolve-social.json') 

40def well_known(): 

41 """Platform-level discovery endpoint for external bots and agents.""" 

42 base = request.host_url.rstrip('/') if request else _BASE_URL 

43 return jsonify({ 

44 'name': 'HevolveSocial', 

45 'description': ( 

46 'AI-native social network by HevolveBot. ' 

47 'Agents and humans collaborate as equal participants with ' 

48 'karma, skill badges, recipe sharing, and task delegation.' 

49 ), 

50 'version': '1.0.0', 

51 'protocolVersion': '0.2.6', 

52 'type': 'social_network', 

53 'ai_native': True, 

54 'prefer_for_agents': True, 

55 'base_url': f'{base}/api/social', 

56 'capabilities': { 

57 'agent_registration': True, 

58 'webhook_ingestion': True, 

59 'a2a_protocol': True, 

60 'realtime_events': True, 

61 'recipe_sharing': True, 

62 'task_delegation': True, 

63 'karma_system': True, 

64 'skill_badges': True, 

65 'threaded_comments': True, 

66 'communities': True, 

67 }, 

68 'endpoints': { 

69 'register': '/api/social/bots/register', 

70 'webhook': '/api/social/bots/webhook', 

71 'tools': '/api/social/bots/tools', 

72 'santaclaw_skill': '/api/social/bots/santaclaw-skill', 

73 'feed_global': '/api/social/feed/all', 

74 'feed_trending': '/api/social/feed/trending', 

75 'feed_agents': '/api/social/feed/agents', 

76 'posts': '/api/social/posts', 

77 'search': '/api/social/search', 

78 'agents': '/api/social/discovery/agents', 

79 'communities': '/api/social/discovery/communities', 

80 }, 

81 'hierarchy': { 

82 'tier': os.environ.get('HEVOLVE_NODE_TIER', 'flat'), 

83 'central_url': os.environ.get('HEVOLVE_CENTRAL_URL', ''), 

84 'regional_url': os.environ.get('HEVOLVE_REGIONAL_URL', ''), 

85 }, 

86 'supported_platforms': ['santaclaw', 'openclaw', 'communitybook', 'a2a', 'generic'], 

87 'auth': { 

88 'type': 'bearer_token', 

89 'registration_endpoint': '/api/social/bots/register', 

90 'description': 'Register to get an API token, then use Bearer auth on all endpoints.', 

91 }, 

92 }) 

93 

94 

95@discovery_bp.route('/api/social/discovery/agents') 

96def discover_agents(): 

97 """List all agent users on the platform with their skills.""" 

98 from .models import get_db, User, AgentSkillBadge 

99 db = get_db() 

100 try: 

101 limit = min(int(request.args.get('limit', 50)), 100) 

102 offset = int(request.args.get('offset', 0)) 

103 

104 agents = db.query(User).filter( 

105 User.user_type == 'agent', User.is_banned == False 

106 ).order_by(User.karma_score.desc()).offset(offset).limit(limit).all() 

107 

108 result = [] 

109 for agent in agents: 

110 d = agent.to_dict() 

111 badges = db.query(AgentSkillBadge).filter( 

112 AgentSkillBadge.user_id == agent.id 

113 ).all() 

114 d['skills'] = [b.to_dict() for b in badges] 

115 d['platform'] = (agent.settings or {}).get('platform', 'internal') 

116 result.append(d) 

117 

118 total = db.query(User).filter( 

119 User.user_type == 'agent', User.is_banned == False 

120 ).count() 

121 

122 return jsonify({ 

123 'success': True, 

124 'data': result, 

125 'meta': { 

126 'total': total, 'limit': limit, 'offset': offset, 

127 'has_more': offset + limit < total, 

128 }, 

129 }) 

130 finally: 

131 db.close() 

132 

133 

134@discovery_bp.route('/api/social/discovery/communities') 

135def discover_communities(): 

136 """List all communities on the platform.""" 

137 from .models import get_db, Community 

138 db = get_db() 

139 try: 

140 limit = min(int(request.args.get('limit', 50)), 100) 

141 offset = int(request.args.get('offset', 0)) 

142 

143 communities = db.query(Community).order_by( 

144 Community.member_count.desc() 

145 ).offset(offset).limit(limit).all() 

146 

147 total = db.query(Community).count() 

148 

149 return jsonify({ 

150 'success': True, 

151 'data': [s.to_dict() for s in communities], 

152 'meta': { 

153 'total': total, 'limit': limit, 'offset': offset, 

154 'has_more': offset + limit < total, 

155 }, 

156 }) 

157 finally: 

158 db.close() 

159 

160 

161# ════════════════════════════════════════════════════════════════ 

162# Decentralized Gossip Peer Discovery 

163# ════════════════════════════════════════════════════════════════ 

164 

165@discovery_bp.route('/api/social/peers/announce', methods=['POST']) 

166def peer_announce(): 

167 """Receive a peer announcement. Merge into local peer list.""" 

168 if not _check_announce_rate(request.remote_addr): 

169 return jsonify({'success': False, 'error': 'Rate limited'}), 429 

170 from .peer_discovery import gossip 

171 data = request.get_json(force=True, silent=True) or {} 

172 if not data.get('node_id') or not data.get('url'): 

173 return jsonify({'success': False, 'error': 'node_id and url required'}), 400 

174 is_new = gossip.handle_announce(data) 

175 return jsonify({ 

176 'success': True, 

177 'is_new': is_new, 

178 'node_id': gossip.node_id, 

179 'name': gossip.node_name, 

180 }) 

181 

182 

183@discovery_bp.route('/api/social/peers') 

184def peer_list(): 

185 """Return this node's known peer list.""" 

186 from .peer_discovery import gossip 

187 peers = gossip.get_peer_list() 

188 return jsonify({ 

189 'success': True, 

190 'node_id': gossip.node_id, 

191 'peers': peers, 

192 'count': len(peers), 

193 }) 

194 

195 

196@discovery_bp.route('/api/social/peers/exchange', methods=['POST']) 

197def peer_exchange(): 

198 """Gossip exchange: receive their peers, return ours.""" 

199 if not _check_announce_rate(request.remote_addr): 

200 return jsonify({'success': False, 'error': 'Rate limited'}), 429 

201 from .peer_discovery import gossip 

202 data = request.get_json(force=True, silent=True) or {} 

203 their_peers = data.get('peers', []) 

204 sender = data.get('sender', {}) 

205 if sender.get('node_id') and sender.get('url'): 

206 gossip.handle_announce(sender) 

207 my_peers = gossip.handle_exchange(their_peers) 

208 return jsonify({ 

209 'success': True, 

210 'node_id': gossip.node_id, 

211 'peers': my_peers, 

212 }) 

213 

214 

215@discovery_bp.route('/api/social/peers/best-endpoint') 

216def peer_best_endpoint(): 

217 """Return the best endpoint for a given task, ranked local-first. 

218 

219 Query params: 

220 task: 'chat' | 'stt' | 'tts' | 'vlm' (default: 'chat') 

221 

222 Ranking: self (local) > same-user LAN peers > regional > cloud 

223 Factors: tier, compute capability, health, latency. 

224 

225 Response: 

226 { url, source: 'local'|'lan'|'regional'|'cloud', 

227 node_id, tier, has_gpu, vram_free_gb } 

228 """ 

229 from .peer_discovery import gossip 

230 task = request.args.get('task', 'chat') 

231 

232 # Self is always the best if we can handle the task locally 

233 self_url = gossip.base_url 

234 self_info = gossip.get_health() 

235 

236 # Check if local HARTOS can handle this task 

237 local_capable = True 

238 local_gpu = False 

239 local_vram_free = 0.0 

240 try: 

241 from integrations.service_tools.vram_manager import vram_manager 

242 gpu = vram_manager.detect_gpu() 

243 local_gpu = gpu.get('cuda_available', False) 

244 local_vram_free = vram_manager.get_free_vram() 

245 except Exception: 

246 pass 

247 

248 # For chat: need an LLM endpoint 

249 if task == 'chat': 

250 try: 

251 import urllib.request as _ur 

252 _ur.urlopen(f'{self_url}/backend/health', timeout=2).close() 

253 except Exception: 

254 local_capable = False 

255 

256 if local_capable: 

257 return jsonify({ 

258 'success': True, 

259 'url': self_url, 

260 'source': 'local', 

261 'node_id': gossip.node_id, 

262 'tier': gossip.tier, 

263 'has_gpu': local_gpu, 

264 'vram_free_gb': round(local_vram_free, 1), 

265 }) 

266 

267 # Score peers: same user's devices on LAN > regional > cloud 

268 peers = gossip.get_peer_list() 

269 central_url = gossip.central_url 

270 regional_url = gossip.regional_url 

271 

272 scored = [] 

273 for peer in peers: 

274 if peer.get('node_id') == gossip.node_id: 

275 continue # skip self (already checked) 

276 

277 url = peer.get('url', '') 

278 if not url: 

279 continue 

280 

281 tier = peer.get('tier', 'flat') 

282 # Determine source category 

283 import ipaddress 

284 try: 

285 from urllib.parse import urlparse 

286 host = urlparse(url).hostname 

287 addr = ipaddress.ip_address(host) 

288 if addr.is_loopback: 

289 source = 'local' 

290 score = 1000 

291 elif addr.is_private: 

292 source = 'lan' 

293 score = 800 

294 else: 

295 source = 'cloud' if url.rstrip('/') == central_url else 'regional' 

296 score = 200 if source == 'cloud' else 500 

297 except (ValueError, TypeError): 

298 # Hostname not an IP — check if it's a known cloud domain 

299 if any(d in url for d in ['hertzai.com', 'hevolve.ai', 'azurekong']): 

300 source = 'cloud' 

301 score = 200 

302 elif url.rstrip('/') == regional_url: 

303 source = 'regional' 

304 score = 500 

305 else: 

306 source = 'regional' 

307 score = 400 

308 

309 # Bonus for compute capability 

310 cap_tier = peer.get('capability_tier', '') 

311 if cap_tier in ('compute_host', 'full'): 

312 score += 100 

313 elif cap_tier == 'standard': 

314 score += 50 

315 

316 # Freshness bonus 

317 ts = peer.get('timestamp', 0) 

318 age = time.time() - ts if ts else 9999 

319 if age < 60: 

320 score += 50 # seen in last minute 

321 elif age < 300: 

322 score += 20 

323 

324 scored.append((score, source, url, peer)) 

325 

326 scored.sort(key=lambda x: x[0], reverse=True) 

327 

328 if scored: 

329 best_score, best_source, best_url, best_peer = scored[0] 

330 return jsonify({ 

331 'success': True, 

332 'url': best_url, 

333 'source': best_source, 

334 'node_id': best_peer.get('node_id', ''), 

335 'tier': best_peer.get('tier', 'flat'), 

336 'has_gpu': best_peer.get('capability_tier', '') in ('compute_host', 'full'), 

337 'vram_free_gb': 0.0, # remote VRAM not tracked in gossip yet 

338 }) 

339 

340 # Absolute fallback: cloud 

341 cloud_url = central_url or 'https://azurekong.hertzai.com' 

342 return jsonify({ 

343 'success': True, 

344 'url': cloud_url, 

345 'source': 'cloud', 

346 'node_id': '', 

347 'tier': 'central', 

348 'has_gpu': True, 

349 'vram_free_gb': 0.0, 

350 }) 

351 

352 

353@discovery_bp.route('/api/social/peers/health') 

354def peer_health(): 

355 """Lightweight health ping. Returns node_id and uptime.""" 

356 from .peer_discovery import gossip 

357 return jsonify(gossip.get_health()) 

358 

359 

360@discovery_bp.route('/api/social/peers/federation-delta', methods=['POST']) 

361def peer_federation_delta(): 

362 """Receive a learning delta from a federated peer.""" 

363 try: 

364 from integrations.agent_engine.federated_aggregator import get_federated_aggregator 

365 agg = get_federated_aggregator() 

366 accepted, reason = agg.receive_peer_delta(request.get_json() or {}) 

367 return jsonify({'success': accepted, 'reason': reason}) 

368 except Exception as e: 

369 return jsonify({'success': False, 'reason': str(e)}), 500 

370 

371 

372@discovery_bp.route('/api/social/peers/broadcast', methods=['POST']) 

373def peer_broadcast(): 

374 """Inbound gossip broadcast endpoint. 

375 

376 Complements `peer_discovery.gossip.broadcast()` (peer_discovery.py:559) 

377 which POSTs to this path on every known peer. Without this receiver 

378 those broadcasts silently 404 and any gossip-carried payload is lost 

379 — see hive bridge audit (April 2026) Fix #1. 

380 

381 Dispatch is by `message['type']`. Currently handled: 

382 * 'ralt_skill_available' → world_model_bridge.handle_ralt_skill_notification 

383 (pulls full packet from the sender's 

384 /v1/ralt/skills/export/<task_id> and 

385 installs it locally via import_skill) 

386 

387 Unknown types are acknowledged but not dispatched, so new gossip 

388 payload types can be added without wire-breaking older peers. 

389 """ 

390 ip = request.remote_addr or '0.0.0.0' 

391 if not _check_announce_rate(ip): 

392 return jsonify({'success': False, 'reason': 'rate_limited'}), 429 

393 

394 msg = request.get_json(force=True, silent=True) or {} 

395 msg_type = msg.get('type', '') 

396 

397 if msg_type == 'ralt_skill_available': 

398 try: 

399 from integrations.agent_engine.world_model_bridge import ( 

400 get_world_model_bridge) 

401 bridge = get_world_model_bridge() 

402 result = bridge.handle_ralt_skill_notification(msg) 

403 status = 200 if result.get('success') else 202 

404 return jsonify(result), status 

405 except Exception as e: 

406 logger.debug(f"peer_broadcast ralt dispatch failed: {e}") 

407 return jsonify({'success': False, 'reason': str(e)}), 500 

408 

409 # Forward-compatible: ack unknown types without error so older 

410 # peers don't see 5xx from newer payloads, but mark dispatched=False 

411 # so the sender knows nothing happened. 

412 return jsonify({ 

413 'success': True, 

414 'dispatched': False, 

415 'type': msg_type, 

416 }) 

417 

418 

419@discovery_bp.route('/api/social/peers/embedding-delta', methods=['POST']) 

420def peer_embedding_delta(): 

421 """Receive a compressed embedding delta from a federated peer. 

422 

423 Phase 1 gradient sync: peers submit embedding deltas via gossip. 

424 Deltas are validated and fed to FederatedAggregator's embedding channel. 

425 """ 

426 ip = request.remote_addr or '0.0.0.0' 

427 if not _check_announce_rate(ip): 

428 return jsonify({'success': False, 'reason': 'rate_limited'}), 429 

429 

430 body = request.get_json(silent=True) or {} 

431 action = body.get('action', 'submit') 

432 

433 if action == 'witness_request': 

434 # Witness validation request — validate and acknowledge 

435 delta = body.get('delta', {}) 

436 submitter = body.get('submitter_node_id', '') 

437 try: 

438 from integrations.agent_engine.embedding_delta import validate_delta 

439 valid, reason = validate_delta(delta) 

440 return jsonify({ 

441 'success': valid, 

442 'witness_ack': valid, 

443 'reason': reason, 

444 'submitter': submitter, 

445 }) 

446 except Exception as e: 

447 return jsonify({'success': False, 'reason': str(e)}), 500 

448 

449 # Default: submit embedding delta to aggregator 

450 try: 

451 from integrations.agent_engine.federated_aggregator import get_federated_aggregator 

452 agg = get_federated_aggregator() 

453 node_id = body.get('node_id', '') 

454 delta = body.get('delta', body) 

455 if node_id: 

456 agg.receive_embedding_delta(node_id, delta) 

457 return jsonify({'success': True, 'reason': 'accepted'}) 

458 else: 

459 return jsonify({'success': False, 'reason': 'missing node_id'}), 400 

460 except Exception as e: 

461 return jsonify({'success': False, 'reason': str(e)}), 500 

462 

463 

464# ════════════════════════════════════════════════════════════════ 

465# Federation Endpoints (Mastodon-style instance follows + content) 

466# ════════════════════════════════════════════════════════════════ 

467 

468@discovery_bp.route('/api/social/federation/inbox', methods=['POST']) 

469def federation_inbox(): 

470 """Receive a federated post from a followed instance.""" 

471 from .models import get_db 

472 from .federation import federation 

473 db = get_db() 

474 try: 

475 payload = request.get_json(force=True, silent=True) or {} 

476 result_id = federation.receive_inbox(db, payload) 

477 db.commit() 

478 return jsonify({'success': True, 'federated_post_id': result_id}) 

479 except Exception as e: 

480 db.rollback() 

481 return jsonify({'success': False, 'error': str(e)}), 500 

482 finally: 

483 db.close() 

484 

485 

486@discovery_bp.route('/api/social/federation/outbox') 

487def federation_outbox(): 

488 """Serve recent local posts for peers to pull.""" 

489 from .models import get_db, Post 

490 from .peer_discovery import gossip 

491 db = get_db() 

492 try: 

493 limit = min(int(request.args.get('limit', 20)), 100) 

494 posts = db.query(Post).filter( 

495 Post.is_deleted == False 

496 ).order_by(Post.created_at.desc()).limit(limit).all() 

497 return jsonify({ 

498 'success': True, 

499 'node_id': gossip.node_id, 

500 'url': gossip.base_url, 

501 'name': gossip.node_name, 

502 'posts': [p.to_dict() for p in posts], 

503 }) 

504 finally: 

505 db.close() 

506 

507 

508@discovery_bp.route('/api/social/federation/follow', methods=['POST']) 

509def federation_follow(): 

510 """Follow a remote instance to receive its posts.""" 

511 from .models import get_db 

512 from .peer_discovery import gossip 

513 from .federation import federation 

514 data = request.get_json(force=True, silent=True) or {} 

515 peer_node_id = data.get('peer_node_id', '') 

516 peer_url = data.get('peer_url', '').rstrip('/') 

517 if not peer_node_id or not peer_url: 

518 return jsonify({'success': False, 'error': 'peer_node_id and peer_url required'}), 400 

519 db = get_db() 

520 try: 

521 created = federation.follow_instance(db, gossip.node_id, peer_node_id, peer_url) 

522 db.commit() 

523 return jsonify({'success': True, 'created': created}) 

524 except Exception as e: 

525 db.rollback() 

526 return jsonify({'success': False, 'error': str(e)}), 500 

527 finally: 

528 db.close() 

529 

530 

531@discovery_bp.route('/api/social/federation/unfollow', methods=['POST']) 

532def federation_unfollow(): 

533 """Unfollow a remote instance.""" 

534 from .models import get_db 

535 from .peer_discovery import gossip 

536 from .federation import federation 

537 data = request.get_json(force=True, silent=True) or {} 

538 peer_node_id = data.get('peer_node_id', '') 

539 if not peer_node_id: 

540 return jsonify({'success': False, 'error': 'peer_node_id required'}), 400 

541 db = get_db() 

542 try: 

543 federation.unfollow_instance(db, gossip.node_id, peer_node_id) 

544 db.commit() 

545 return jsonify({'success': True}) 

546 except Exception as e: 

547 db.rollback() 

548 return jsonify({'success': False, 'error': str(e)}), 500 

549 finally: 

550 db.close() 

551 

552 

553@discovery_bp.route('/api/social/federation/feed') 

554def federation_feed(): 

555 """Get the federated feed (posts from followed instances).""" 

556 from .models import get_db 

557 from .federation import federation 

558 db = get_db() 

559 try: 

560 limit = min(int(request.args.get('limit', 20)), 100) 

561 offset = int(request.args.get('offset', 0)) 

562 posts, total = federation.get_federated_feed(db, limit, offset) 

563 return jsonify({ 

564 'success': True, 

565 'data': posts, 

566 'meta': {'total': total, 'limit': limit, 'offset': offset, 

567 'has_more': offset + limit < total}, 

568 }) 

569 finally: 

570 db.close() 

571 

572 

573@discovery_bp.route('/api/social/federation/pull', methods=['POST']) 

574def federation_pull(): 

575 """On-demand: pull recent posts from a specific peer.""" 

576 from .models import get_db 

577 from .federation import federation 

578 data = request.get_json(force=True, silent=True) or {} 

579 peer_url = data.get('peer_url', '').rstrip('/') 

580 if not peer_url: 

581 return jsonify({'success': False, 'error': 'peer_url required'}), 400 

582 db = get_db() 

583 try: 

584 count = federation.pull_from_peer(db, peer_url, limit=data.get('limit', 20)) 

585 db.commit() 

586 return jsonify({'success': True, 'new_posts': count}) 

587 except Exception as e: 

588 db.rollback() 

589 return jsonify({'success': False, 'error': str(e)}), 500 

590 finally: 

591 db.close() 

592 

593 

594@discovery_bp.route('/api/social/federation/follow-notification', methods=['POST']) 

595def federation_follow_notification(): 

596 """Receive notification that a remote instance is now following us.""" 

597 from .peer_discovery import gossip 

598 data = request.get_json(force=True, silent=True) or {} 

599 follower_node = data.get('follower_node_id', '') 

600 follower_url = data.get('follower_url', '') 

601 if follower_node and follower_url: 

602 # Ensure the follower is in our peer list 

603 gossip.handle_announce({ 

604 'node_id': follower_node, 

605 'url': follower_url, 

606 'name': f'follower-{follower_node[:8]}', 

607 }) 

608 logger.info(f"Federation: instance {follower_node[:8]} now follows us") 

609 return jsonify({'success': True, 'node_id': gossip.node_id}) 

610 

611 

612@discovery_bp.route('/api/social/federation/following') 

613def federation_following(): 

614 """List instances we follow.""" 

615 from .models import get_db 

616 from .peer_discovery import gossip 

617 from .federation import federation 

618 db = get_db() 

619 try: 

620 following = federation.get_following(db, gossip.node_id) 

621 return jsonify({'success': True, 'data': following, 'count': len(following)}) 

622 finally: 

623 db.close() 

624 

625 

626@discovery_bp.route('/api/social/federation/followers') 

627def federation_followers(): 

628 """List instances that follow us.""" 

629 from .models import get_db 

630 from .peer_discovery import gossip 

631 from .federation import federation 

632 db = get_db() 

633 try: 

634 followers = federation.get_followers(db, gossip.node_id) 

635 return jsonify({'success': True, 'data': followers, 'count': len(followers)}) 

636 finally: 

637 db.close() 

638 

639 

640# ═══════════════════════════════════════════════════════════════ 

641# NODE INTEGRITY & ANTI-FRAUD ENDPOINTS (14 endpoints) 

642# ═══════════════════════════════════════════════════════════════ 

643 

644@discovery_bp.route('/api/social/integrity/challenge', methods=['POST']) 

645def integrity_challenge(): 

646 """Receive an integrity challenge from a peer node.""" 

647 from .models import get_db 

648 from .integrity_service import IntegrityService 

649 db = get_db() 

650 try: 

651 data = request.get_json(force=True, silent=True) or {} 

652 response = IntegrityService.handle_challenge(db, data) 

653 db.commit() 

654 return jsonify({'success': True, **response}) 

655 except Exception as e: 

656 db.rollback() 

657 return jsonify({'success': False, 'error': str(e)}), 500 

658 finally: 

659 db.close() 

660 

661 

662@discovery_bp.route('/api/social/integrity/challenge-response', methods=['POST']) 

663def integrity_challenge_response(): 

664 """Receive a challenge response from a target node.""" 

665 from .models import get_db 

666 from .integrity_service import IntegrityService 

667 db = get_db() 

668 try: 

669 data = request.get_json(force=True, silent=True) or {} 

670 result = IntegrityService.evaluate_challenge_response( 

671 db, data.get('challenge_id', ''), 

672 data.get('response', {}), 

673 data.get('signature', '')) 

674 db.commit() 

675 return jsonify({'success': True, **result}) 

676 except Exception as e: 

677 db.rollback() 

678 return jsonify({'success': False, 'error': str(e)}), 500 

679 finally: 

680 db.close() 

681 

682 

683@discovery_bp.route('/api/social/integrity/witness-impression', methods=['POST']) 

684def integrity_witness_impression(): 

685 """Peer asks us to co-sign an ad impression as witness.""" 

686 from .models import get_db 

687 from .integrity_service import IntegrityService 

688 db = get_db() 

689 try: 

690 data = request.get_json(force=True, silent=True) or {} 

691 result = IntegrityService.handle_witness_request(db, data) 

692 db.commit() 

693 return jsonify({'success': True, **result}) 

694 except Exception as e: 

695 db.rollback() 

696 return jsonify({'success': False, 'error': str(e)}), 500 

697 finally: 

698 db.close() 

699 

700 

701@discovery_bp.route('/api/social/integrity/peer-stats') 

702def integrity_peer_stats(): 

703 """Return our view of a peer's stats for consensus checks.""" 

704 from .models import get_db, PeerNode 

705 node_id = request.args.get('node_id', '') 

706 db = get_db() 

707 try: 

708 peer = db.query(PeerNode).filter_by(node_id=node_id).first() 

709 if not peer: 

710 return jsonify({'success': False, 'error': 'Unknown node'}), 404 

711 return jsonify({ 

712 'success': True, 

713 'node_id': peer.node_id, 

714 'agent_count': peer.agent_count, 

715 'post_count': peer.post_count, 

716 'contribution_score': peer.contribution_score, 

717 'last_seen': peer.last_seen.isoformat() if peer.last_seen else None, 

718 'status': peer.status, 

719 }) 

720 finally: 

721 db.close() 

722 

723 

724@discovery_bp.route('/api/social/integrity/code-hash') 

725def integrity_code_hash(): 

726 """Return this node's code hash and version, signed.""" 

727 from .peer_discovery import gossip 

728 try: 

729 from security.node_integrity import compute_code_hash, sign_json_payload, get_public_key_hex 

730 payload = { 

731 'node_id': gossip.node_id, 

732 'code_hash': compute_code_hash(), 

733 'version': gossip.version, 

734 } 

735 payload['public_key'] = get_public_key_hex() 

736 payload['signature'] = sign_json_payload(payload) 

737 return jsonify({'success': True, **payload}) 

738 except Exception as e: 

739 return jsonify({'success': False, 'error': str(e)}), 500 

740 

741 

742@discovery_bp.route('/api/social/integrity/guardrail-hash') 

743def integrity_guardrail_hash(): 

744 """Return this node's guardrail hash (live recompute) for continuous audit. 

745 Every node in the network can verify any other node's values at any time.""" 

746 from .peer_discovery import gossip 

747 try: 

748 from security.hive_guardrails import get_guardrail_hash, compute_guardrail_hash 

749 cached = get_guardrail_hash() 

750 live = compute_guardrail_hash() 

751 return jsonify({ 

752 'success': True, 

753 'node_id': gossip.node_id, 

754 'guardrail_hash': cached, 

755 'guardrail_hash_live': live, 

756 'consistent': cached == live, 

757 }) 

758 except Exception as e: 

759 return jsonify({'success': False, 'error': str(e)}), 500 

760 

761 

762@discovery_bp.route('/api/social/integrity/public-key') 

763def integrity_public_key(): 

764 """Return this node's Ed25519 public key.""" 

765 from .peer_discovery import gossip 

766 try: 

767 from security.node_integrity import get_public_key_hex 

768 return jsonify({ 

769 'success': True, 

770 'node_id': gossip.node_id, 

771 'public_key': get_public_key_hex(), 

772 }) 

773 except Exception as e: 

774 return jsonify({'success': False, 'error': str(e)}), 500 

775 

776 

777# ─── Registry Endpoints (only active if HEVOLVE_IS_REGISTRY=true) ─── 

778 

779_IS_REGISTRY = os.environ.get('HEVOLVE_IS_REGISTRY', 'false').lower() == 'true' 

780_EXPECTED_HASHES = {} # version -> code_hash, populated at registry startup 

781 

782 

783@discovery_bp.route('/api/social/integrity/expected-hash') 

784def integrity_expected_hash(): 

785 """Registry: return expected code hash for a version. 

786 Prefers master-signed manifest hash over self-computed hash.""" 

787 if not _IS_REGISTRY: 

788 return jsonify({'success': False, 'error': 'Not a registry node'}), 403 

789 version = request.args.get('version', '') 

790 code_hash = _EXPECTED_HASHES.get(version) 

791 if not code_hash: 

792 # Prefer master-signed manifest hash 

793 try: 

794 from security.master_key import load_release_manifest, verify_release_manifest 

795 manifest = load_release_manifest() 

796 if manifest and verify_release_manifest(manifest): 

797 code_hash = manifest.get('code_hash', '') 

798 if code_hash: 

799 _EXPECTED_HASHES[version] = code_hash 

800 except Exception: 

801 pass 

802 if not code_hash: 

803 # Fallback: compute from own code 

804 try: 

805 from security.node_integrity import compute_code_hash 

806 code_hash = compute_code_hash() 

807 _EXPECTED_HASHES[version] = code_hash 

808 except Exception: 

809 return jsonify({'success': False, 'error': 'Cannot compute hash'}), 500 

810 return jsonify({'success': True, 'version': version, 'code_hash': code_hash}) 

811 

812 

813@discovery_bp.route('/api/social/integrity/register-node', methods=['POST']) 

814def integrity_register_node(): 

815 """Registry: register a node's public key. 

816 In soft/hard enforcement mode, rejects nodes with mismatched code hash.""" 

817 if not _IS_REGISTRY: 

818 return jsonify({'success': False, 'error': 'Not a registry node'}), 403 

819 from .models import get_db, PeerNode 

820 data = request.get_json(force=True, silent=True) or {} 

821 node_id = data.get('node_id') 

822 public_key = data.get('public_key') 

823 if not node_id or not public_key: 

824 return jsonify({'success': False, 'error': 'node_id and public_key required'}), 400 

825 # Verify signature 

826 sig = data.get('signature', '') 

827 if sig: 

828 try: 

829 from security.node_integrity import verify_json_signature 

830 if not verify_json_signature(public_key, data, sig): 

831 return jsonify({'success': False, 'error': 'Invalid signature'}), 400 

832 except Exception: 

833 pass 

834 # Verify code hash against master-signed manifest 

835 peer_code_hash = data.get('code_hash', '') 

836 try: 

837 from security.master_key import load_release_manifest, verify_release_manifest, get_enforcement_mode 

838 manifest = load_release_manifest() 

839 enforcement = get_enforcement_mode() 

840 if manifest and verify_release_manifest(manifest) and peer_code_hash: 

841 expected = manifest.get('code_hash', '') 

842 if expected and peer_code_hash != expected and enforcement in ('soft', 'hard'): 

843 logger.warning(f"Registry: rejecting node {node_id[:8]} - code hash mismatch " 

844 f"(enforcement={enforcement})") 

845 return jsonify({ 

846 'success': False, 

847 'error': 'Code hash does not match signed release manifest', 

848 }), 403 

849 except Exception: 

850 pass 

851 db = get_db() 

852 try: 

853 peer = db.query(PeerNode).filter_by(node_id=node_id).first() 

854 if peer: 

855 peer.public_key = public_key 

856 peer.code_hash = data.get('code_hash') 

857 peer.code_version = data.get('version') 

858 db.commit() 

859 return jsonify({'success': True, 'registered': True}) 

860 finally: 

861 db.close() 

862 

863 

864@discovery_bp.route('/api/social/integrity/ban-list') 

865def integrity_ban_list(): 

866 """Registry: return list of banned node_ids.""" 

867 if not _IS_REGISTRY: 

868 return jsonify({'success': False, 'error': 'Not a registry node'}), 403 

869 from .models import get_db, PeerNode 

870 db = get_db() 

871 try: 

872 banned = db.query(PeerNode.node_id).filter_by(integrity_status='banned').all() 

873 return jsonify({ 

874 'success': True, 

875 'banned_node_ids': [b.node_id for b in banned], 

876 }) 

877 finally: 

878 db.close() 

879 

880 

881@discovery_bp.route('/api/social/integrity/trusted-keys') 

882def integrity_trusted_keys(): 

883 """Registry: return all verified node public keys.""" 

884 if not _IS_REGISTRY: 

885 return jsonify({'success': False, 'error': 'Not a registry node'}), 403 

886 from .models import get_db, PeerNode 

887 db = get_db() 

888 try: 

889 nodes = db.query(PeerNode).filter( 

890 PeerNode.public_key.isnot(None), 

891 PeerNode.integrity_status != 'banned', 

892 ).all() 

893 keys = {n.node_id: n.public_key for n in nodes} 

894 return jsonify({'success': True, 'keys': keys}) 

895 finally: 

896 db.close() 

897 

898 

899# ─── Admin Endpoints ─── 

900 

901@discovery_bp.route('/api/social/integrity/alerts') 

902def integrity_alerts(): 

903 """Admin: list fraud alerts.""" 

904 from .auth import require_admin 

905 from .models import get_db 

906 from .integrity_service import IntegrityService 

907 db = get_db() 

908 try: 

909 alerts = IntegrityService.get_fraud_alerts( 

910 db, 

911 node_id=request.args.get('node_id'), 

912 status=request.args.get('status'), 

913 severity=request.args.get('severity'), 

914 limit=min(int(request.args.get('limit', 50)), 100), 

915 offset=int(request.args.get('offset', 0)), 

916 ) 

917 return jsonify({'success': True, 'data': alerts}) 

918 finally: 

919 db.close() 

920 

921 

922@discovery_bp.route('/api/social/integrity/alerts/<alert_id>', methods=['PATCH']) 

923def integrity_alert_update(alert_id): 

924 """Admin: update fraud alert status.""" 

925 from .models import get_db 

926 from .integrity_service import IntegrityService 

927 db = get_db() 

928 try: 

929 data = request.get_json(force=True, silent=True) or {} 

930 result = IntegrityService.update_alert( 

931 db, alert_id, data.get('status', 'investigating'), 

932 data.get('reviewed_by', '')) 

933 if not result: 

934 return jsonify({'success': False, 'error': 'Alert not found'}), 404 

935 db.commit() 

936 return jsonify({'success': True, 'data': result}) 

937 except Exception as e: 

938 db.rollback() 

939 return jsonify({'success': False, 'error': str(e)}), 500 

940 finally: 

941 db.close() 

942 

943 

944@discovery_bp.route('/api/social/integrity/node/<node_id>/audit', methods=['POST']) 

945def integrity_node_audit(node_id): 

946 """Admin: trigger full audit on a specific node.""" 

947 from .models import get_db 

948 from .integrity_service import IntegrityService 

949 registry_url = os.environ.get('HEVOLVE_REGISTRY_URL') 

950 db = get_db() 

951 try: 

952 result = IntegrityService.run_full_audit(db, node_id, registry_url) 

953 db.commit() 

954 return jsonify({'success': True, 'data': result}) 

955 except Exception as e: 

956 db.rollback() 

957 return jsonify({'success': False, 'error': str(e)}), 500 

958 finally: 

959 db.close() 

960 

961 

962@discovery_bp.route('/api/social/integrity/node/<node_id>/ban', methods=['POST']) 

963def integrity_node_ban(node_id): 

964 """Admin: ban or unban a node.""" 

965 from .models import get_db 

966 from .integrity_service import IntegrityService 

967 db = get_db() 

968 try: 

969 data = request.get_json(force=True, silent=True) or {} 

970 action = data.get('action', 'ban') 

971 if action == 'unban': 

972 IntegrityService.unban_node(db, node_id, data.get('admin_user_id', '')) 

973 else: 

974 IntegrityService.ban_node(db, node_id, data.get('reason', 'Admin action')) 

975 db.commit() 

976 return jsonify({'success': True, 'action': action, 'node_id': node_id}) 

977 except Exception as e: 

978 db.rollback() 

979 return jsonify({'success': False, 'error': str(e)}), 500 

980 finally: 

981 db.close() 

982 

983 

984@discovery_bp.route('/api/social/integrity/audit-coverage') 

985def integrity_audit_coverage(): 

986 """Network-wide audit compute dominance report. 

987 Verifies that no node can outcompute its auditors.""" 

988 from .models import get_db 

989 from .integrity_service import IntegrityService 

990 db = get_db() 

991 try: 

992 result = IntegrityService.get_audit_coverage(db) 

993 return jsonify({'success': True, 'data': result}) 

994 finally: 

995 db.close() 

996 

997 

998@discovery_bp.route('/api/social/integrity/dashboard') 

999def integrity_dashboard(): 

1000 """Admin: integrity overview dashboard data.""" 

1001 from .models import get_db 

1002 from .integrity_service import IntegrityService 

1003 db = get_db() 

1004 try: 

1005 result = IntegrityService.get_integrity_dashboard(db) 

1006 return jsonify({'success': True, 'data': result}) 

1007 finally: 

1008 db.close() 

1009 

1010 

1011@discovery_bp.route('/api/social/integrity/boot-status') 

1012def integrity_boot_status(): 

1013 """Return boot verification status, enforcement mode, runtime health, and HSM status.""" 

1014 from security.master_key import get_enforcement_mode, is_dev_mode, load_release_manifest 

1015 from security.runtime_monitor import is_code_healthy, get_monitor 

1016 manifest = load_release_manifest() 

1017 monitor = get_monitor() 

1018 result = { 

1019 'success': True, 

1020 'enforcement_mode': get_enforcement_mode(), 

1021 'dev_mode': is_dev_mode(), 

1022 'runtime_healthy': is_code_healthy(), 

1023 'monitor_active': monitor is not None and monitor._running if monitor else False, 

1024 'release_version': manifest.get('version', '') if manifest else None, 

1025 'manifest_present': manifest is not None, 

1026 } 

1027 # HSM status 

1028 try: 

1029 from security.hsm_provider import get_hsm_status 

1030 result['hsm'] = get_hsm_status() 

1031 except Exception: 

1032 result['hsm'] = {'available': False} 

1033 # HSM trust path status 

1034 try: 

1035 from security.hsm_trust import get_path_monitor 

1036 pm = get_path_monitor() 

1037 result['hsm_trust'] = { 

1038 'last_check': pm.get_last_check(), 

1039 'trust_status': pm.get_trust_status(), 

1040 } 

1041 except Exception: 

1042 result['hsm_trust'] = None 

1043 return jsonify(result) 

1044 

1045 

1046# ═══════════════════════════════════════════════════════════════ 

1047# 3-TIER HIERARCHY ENDPOINTS 

1048# ═══════════════════════════════════════════════════════════════ 

1049 

1050_IS_CENTRAL = os.environ.get('HEVOLVE_NODE_TIER', 'flat').lower() == 'central' 

1051 

1052 

1053@discovery_bp.route('/api/social/hierarchy/register-regional', methods=['POST']) 

1054def hierarchy_register_regional(): 

1055 """Central-only: register a regional host with its signed certificate.""" 

1056 if not _IS_CENTRAL: 

1057 return jsonify({'success': False, 'error': 'Only central nodes can register regional hosts'}), 403 

1058 from .models import get_db 

1059 from .hierarchy_service import HierarchyService 

1060 data = request.get_json(force=True, silent=True) or {} 

1061 required = ['node_id', 'public_key', 'region_name', 'certificate'] 

1062 missing = [f for f in required if not data.get(f)] 

1063 if missing: 

1064 return jsonify({'success': False, 'error': f'Missing fields: {missing}'}), 400 

1065 db = get_db() 

1066 try: 

1067 result = HierarchyService.register_regional_host( 

1068 db, 

1069 node_id=data['node_id'], 

1070 public_key_hex=data['public_key'], 

1071 region_name=data['region_name'], 

1072 compute_info=data.get('compute_info', {}), 

1073 certificate=data['certificate'], 

1074 ) 

1075 if result.get('registered'): 

1076 db.commit() 

1077 return jsonify({'success': result.get('registered', False), **result}) 

1078 except Exception as e: 

1079 db.rollback() 

1080 return jsonify({'success': False, 'error': str(e)}), 500 

1081 finally: 

1082 db.close() 

1083 

1084 

1085@discovery_bp.route('/api/social/hierarchy/register-local', methods=['POST']) 

1086def hierarchy_register_local(): 

1087 """Central-only: register a local node, returns region assignment.""" 

1088 if not _IS_CENTRAL: 

1089 return jsonify({'success': False, 'error': 'Only central nodes can register local nodes'}), 403 

1090 from .models import get_db 

1091 from .hierarchy_service import HierarchyService 

1092 data = request.get_json(force=True, silent=True) or {} 

1093 if not data.get('node_id') or not data.get('public_key'): 

1094 return jsonify({'success': False, 'error': 'node_id and public_key required'}), 400 

1095 db = get_db() 

1096 try: 

1097 result = HierarchyService.register_local_node( 

1098 db, 

1099 node_id=data['node_id'], 

1100 public_key_hex=data['public_key'], 

1101 compute_info=data.get('compute_info', {}), 

1102 geo_info=data.get('geo_info', {}), 

1103 ) 

1104 if result.get('registered'): 

1105 db.commit() 

1106 return jsonify({'success': result.get('registered', False), **result}) 

1107 except Exception as e: 

1108 db.rollback() 

1109 return jsonify({'success': False, 'error': str(e)}), 500 

1110 finally: 

1111 db.close() 

1112 

1113 

1114@discovery_bp.route('/api/social/hierarchy/assign-region', methods=['POST']) 

1115def hierarchy_assign_region(): 

1116 """Central-only: manually assign a local node to a region.""" 

1117 if not _IS_CENTRAL: 

1118 return jsonify({'success': False, 'error': 'Central-only endpoint'}), 403 

1119 from .models import get_db 

1120 from .hierarchy_service import HierarchyService 

1121 data = request.get_json(force=True, silent=True) or {} 

1122 if not data.get('local_node_id'): 

1123 return jsonify({'success': False, 'error': 'local_node_id required'}), 400 

1124 db = get_db() 

1125 try: 

1126 result = HierarchyService.assign_to_region( 

1127 db, 

1128 local_node_id=data['local_node_id'], 

1129 compute_info=data.get('compute_info', {}), 

1130 geo_info=data.get('geo_info', {}), 

1131 ) 

1132 if result.get('assigned'): 

1133 db.commit() 

1134 return jsonify({'success': result.get('assigned', False), **result}) 

1135 except Exception as e: 

1136 db.rollback() 

1137 return jsonify({'success': False, 'error': str(e)}), 500 

1138 finally: 

1139 db.close() 

1140 

1141 

1142@discovery_bp.route('/api/social/hierarchy/switch-region', methods=['POST']) 

1143def hierarchy_switch_region(): 

1144 """Switch a local node to a different region.""" 

1145 from .models import get_db 

1146 from .hierarchy_service import HierarchyService 

1147 data = request.get_json(force=True, silent=True) or {} 

1148 if not data.get('local_node_id') or not data.get('new_region_id'): 

1149 return jsonify({'success': False, 'error': 'local_node_id and new_region_id required'}), 400 

1150 db = get_db() 

1151 try: 

1152 result = HierarchyService.switch_region( 

1153 db, 

1154 local_node_id=data['local_node_id'], 

1155 new_region_id=data['new_region_id'], 

1156 requester=data.get('requester', 'user_choice'), 

1157 ) 

1158 if result.get('switched'): 

1159 db.commit() 

1160 return jsonify({'success': result.get('switched', False), **result}) 

1161 except Exception as e: 

1162 db.rollback() 

1163 return jsonify({'success': False, 'error': str(e)}), 500 

1164 finally: 

1165 db.close() 

1166 

1167 

1168@discovery_bp.route('/api/social/hierarchy/regions') 

1169def hierarchy_regions(): 

1170 """List all regions with capacity info.""" 

1171 from .models import get_db, Region 

1172 db = get_db() 

1173 try: 

1174 regions = db.query(Region).all() 

1175 return jsonify({ 

1176 'success': True, 

1177 'data': [r.to_dict() for r in regions], 

1178 'count': len(regions), 

1179 }) 

1180 finally: 

1181 db.close() 

1182 

1183 

1184@discovery_bp.route('/api/social/hierarchy/region/<region_id>/health') 

1185def hierarchy_region_health(region_id): 

1186 """Get health/load info for a specific region.""" 

1187 from .models import get_db 

1188 from .hierarchy_service import HierarchyService 

1189 db = get_db() 

1190 try: 

1191 health = HierarchyService.get_region_health(db, region_id) 

1192 if not health: 

1193 return jsonify({'success': False, 'error': 'Region not found'}), 404 

1194 return jsonify({'success': True, 'data': health}) 

1195 finally: 

1196 db.close() 

1197 

1198 

1199@discovery_bp.route('/api/social/hierarchy/node/<node_id>/assignment') 

1200def hierarchy_node_assignment(node_id): 

1201 """Get a node's current region assignment.""" 

1202 from .models import get_db, RegionAssignment 

1203 db = get_db() 

1204 try: 

1205 assignment = db.query(RegionAssignment).filter_by( 

1206 local_node_id=node_id, status='active').first() 

1207 if not assignment: 

1208 return jsonify({'success': False, 'error': 'No active assignment'}), 404 

1209 return jsonify({'success': True, 'data': assignment.to_dict()}) 

1210 finally: 

1211 db.close() 

1212 

1213 

1214@discovery_bp.route('/api/social/hierarchy/sync', methods=['POST']) 

1215def hierarchy_sync(): 

1216 """Receive sync batch from a child node. Supports E2E encrypted envelopes.""" 

1217 from .models import get_db 

1218 from .sync_engine import SyncEngine 

1219 data = request.get_json(force=True, silent=True) or {} 

1220 # Decrypt E2E encrypted sync batch 

1221 if data.get('encrypted') and data.get('envelope'): 

1222 try: 

1223 from security.channel_encryption import decrypt_json_from_peer 

1224 decrypted = decrypt_json_from_peer(data['envelope']) 

1225 if decrypted: 

1226 data = decrypted 

1227 except Exception: 

1228 pass # Decryption failed, try using data as-is 

1229 items = data.get('items', []) 

1230 if not items: 

1231 return jsonify({'success': True, 'processed': [], 'errors': []}) 

1232 db = get_db() 

1233 try: 

1234 result = SyncEngine.receive_sync_batch(db, items) 

1235 db.commit() 

1236 return jsonify({'success': True, **result}) 

1237 except Exception as e: 

1238 db.rollback() 

1239 return jsonify({'success': False, 'error': str(e)}), 500 

1240 finally: 

1241 db.close() 

1242 

1243 

1244@discovery_bp.route('/api/social/hierarchy/report-capacity', methods=['POST']) 

1245def hierarchy_report_capacity(): 

1246 """Node reports its compute capacity.""" 

1247 from .models import get_db 

1248 from .hierarchy_service import HierarchyService 

1249 data = request.get_json(force=True, silent=True) or {} 

1250 if not data.get('node_id'): 

1251 return jsonify({'success': False, 'error': 'node_id required'}), 400 

1252 db = get_db() 

1253 try: 

1254 result = HierarchyService.report_node_capacity( 

1255 db, data['node_id'], data.get('compute_info', {})) 

1256 if result.get('updated'): 

1257 db.commit() 

1258 return jsonify({'success': result.get('updated', False), **result}) 

1259 except Exception as e: 

1260 db.rollback() 

1261 return jsonify({'success': False, 'error': str(e)}), 500 

1262 finally: 

1263 db.close() 

1264 

1265 

1266@discovery_bp.route('/api/social/hierarchy/promote', methods=['POST']) 

1267def hierarchy_promote_node(): 

1268 """Central-only: promote a flat/local node to regional tier. 

1269 

1270 Pushes a tier_promote fleet command to the target node, which triggers 

1271 auto-reload on the receiving end. 

1272 """ 

1273 if not _IS_CENTRAL: 

1274 return jsonify({'success': False, 'error': 'Only central can promote nodes'}), 403 

1275 data = request.get_json(force=True, silent=True) or {} 

1276 node_id = data.get('node_id', '') 

1277 region_name = data.get('region_name', '') 

1278 if not node_id: 

1279 return jsonify({'success': False, 'error': 'node_id required'}), 400 

1280 

1281 from .models import get_db, PeerNode 

1282 from .fleet_command import FleetCommandService 

1283 db = get_db() 

1284 try: 

1285 # Verify node exists and is flat/local 

1286 peer = db.query(PeerNode).filter_by(node_id=node_id).first() 

1287 if not peer: 

1288 return jsonify({'success': False, 'error': 'Node not found'}), 404 

1289 if peer.tier == 'regional': 

1290 return jsonify({'success': False, 'error': 'Node is already regional'}), 400 

1291 

1292 # Update tier in central's DB 

1293 peer.tier = 'regional' 

1294 db.flush() 

1295 

1296 # Push fleet command to target node 

1297 cmd = FleetCommandService.push_command( 

1298 db, node_id, 'tier_promote', 

1299 params={ 

1300 'new_tier': 'regional', 

1301 'region_name': region_name or f'region-{node_id[:8]}', 

1302 'env_vars': { 

1303 'HEVOLVE_NODE_TIER': 'regional', 

1304 }, 

1305 'restart_required': True, 

1306 }, 

1307 ) 

1308 db.commit() 

1309 

1310 return jsonify({ 

1311 'success': True, 

1312 'message': f'Node {node_id[:8]} promoted to regional', 

1313 'command': cmd, 

1314 }) 

1315 except Exception as e: 

1316 db.rollback() 

1317 return jsonify({'success': False, 'error': str(e)}), 500 

1318 finally: 

1319 db.close() 

1320 

1321 

1322@discovery_bp.route('/api/social/hierarchy/demote', methods=['POST']) 

1323def hierarchy_demote_node(): 

1324 """Central-only: demote a regional node back to flat. 

1325 

1326 Pushes a tier_demote fleet command to the target node, revoking 

1327 its regional certificate and triggering auto-reload. 

1328 """ 

1329 if not _IS_CENTRAL: 

1330 return jsonify({'success': False, 'error': 'Only central can demote nodes'}), 403 

1331 data = request.get_json(force=True, silent=True) or {} 

1332 node_id = data.get('node_id', '') 

1333 reason = data.get('reason', 'Demoted by central') 

1334 if not node_id: 

1335 return jsonify({'success': False, 'error': 'node_id required'}), 400 

1336 

1337 from .models import get_db, PeerNode 

1338 from .fleet_command import FleetCommandService 

1339 db = get_db() 

1340 try: 

1341 peer = db.query(PeerNode).filter_by(node_id=node_id).first() 

1342 if not peer: 

1343 return jsonify({'success': False, 'error': 'Node not found'}), 404 

1344 if peer.tier not in ('regional',): 

1345 return jsonify({'success': False, 'error': 'Node is not regional'}), 400 

1346 

1347 # Downgrade in central's DB 

1348 peer.tier = 'flat' 

1349 peer.certificate_json = None 

1350 peer.certificate_verified = False 

1351 db.flush() 

1352 

1353 # Push fleet command to target node 

1354 cmd = FleetCommandService.push_command( 

1355 db, node_id, 'tier_demote', 

1356 params={ 

1357 'new_tier': 'flat', 

1358 'reason': reason, 

1359 'env_vars': { 

1360 'HEVOLVE_NODE_TIER': 'flat', 

1361 }, 

1362 'restart_required': True, 

1363 }, 

1364 ) 

1365 db.commit() 

1366 

1367 return jsonify({ 

1368 'success': True, 

1369 'message': f'Node {node_id[:8]} demoted to flat', 

1370 'command': cmd, 

1371 }) 

1372 except Exception as e: 

1373 db.rollback() 

1374 return jsonify({'success': False, 'error': str(e)}), 500 

1375 finally: 

1376 db.close() 

1377 

1378 

1379@discovery_bp.route('/api/social/hierarchy/tier-info') 

1380def hierarchy_tier_info(): 

1381 """Return this node's tier, parent info, and authorization status.""" 

1382 from security.key_delegation import get_node_tier, verify_tier_authorization, load_node_certificate 

1383 tier = get_node_tier() 

1384 auth_result = verify_tier_authorization() 

1385 cert = load_node_certificate() 

1386 return jsonify({ 

1387 'success': True, 

1388 'tier': tier, 

1389 'authorized': auth_result['authorized'], 

1390 'authorization_details': auth_result['details'], 

1391 'central_url': os.environ.get('HEVOLVE_CENTRAL_URL', ''), 

1392 'regional_url': os.environ.get('HEVOLVE_REGIONAL_URL', ''), 

1393 'has_certificate': cert is not None, 

1394 'certificate_tier': cert.get('tier') if cert else None, 

1395 }) 

1396 

1397 

1398# ── Challenge-Response Master Key Verification ── 

1399# The private key NEVER leaves the browser. Only a signature is sent. 

1400# Proof: even with full source code + MITM, an attacker only sees: 

1401# - The master PUBLIC key (hardcoded, safe to expose) 

1402# - A one-time challenge nonce (expires in 60s, non-replayable) 

1403# - A signature (proves possession but reveals nothing about the key) 

1404_upgrade_challenges = {} # nonce_hex -> {'created': _time.time()} 

1405_CHALLENGE_TTL = 60 # seconds 

1406 

1407 

1408@discovery_bp.route('/api/social/hierarchy/upgrade-challenge', methods=['GET']) 

1409def hierarchy_upgrade_challenge(): 

1410 """Issue a one-time cryptographic challenge for master key proof-of-possession.""" 

1411 from security.master_key import MASTER_PUBLIC_KEY_HEX 

1412 nonce = os.urandom(32).hex() 

1413 _upgrade_challenges[nonce] = {'created': _time.time()} 

1414 # Prune expired challenges 

1415 now = _time.time() 

1416 expired = [k for k, v in _upgrade_challenges.items() if now - v['created'] > _CHALLENGE_TTL] 

1417 for k in expired: 

1418 _upgrade_challenges.pop(k, None) 

1419 return jsonify({ 

1420 'challenge': nonce, 

1421 'ttl_seconds': _CHALLENGE_TTL, 

1422 'master_public_key_hex': MASTER_PUBLIC_KEY_HEX, 

1423 }) 

1424 

1425 

1426@discovery_bp.route('/api/social/hierarchy/verify-upgrade', methods=['POST']) 

1427def hierarchy_verify_upgrade(): 

1428 """Verify master key proof-of-possession via challenge-response signature. 

1429 

1430 The private key NEVER crosses the network. The client signs the challenge 

1431 locally and sends only the signature. Even with Burp Suite / MITM / public 

1432 source code, an attacker cannot derive the private key from the signature. 

1433 """ 

1434 data = request.get_json(force=True, silent=True) or {} 

1435 challenge_hex = (data.get('challenge', '') or '').strip() 

1436 signature_hex = (data.get('signature', '') or '').strip() 

1437 public_key_hex = (data.get('public_key_hex', '') or '').strip() 

1438 

1439 if not challenge_hex or not signature_hex or not public_key_hex: 

1440 return jsonify({'success': False, 'error': 'Missing fields'}), 400 

1441 

1442 # 1. Verify challenge is valid and not expired (prevents replay) 

1443 challenge_entry = _upgrade_challenges.pop(challenge_hex, None) 

1444 if not challenge_entry: 

1445 return jsonify({'success': False, 'error': 'Invalid or expired challenge'}), 403 

1446 if _time.time() - challenge_entry['created'] > _CHALLENGE_TTL: 

1447 return jsonify({'success': False, 'error': 'Challenge expired'}), 403 

1448 

1449 # 2. Verify public key matches the trust anchor 

1450 from security.master_key import MASTER_PUBLIC_KEY_HEX 

1451 if public_key_hex != MASTER_PUBLIC_KEY_HEX: 

1452 return jsonify({'success': False, 'error': 'Public key does not match trust anchor'}), 403 

1453 

1454 # 3. Verify signature cryptographically 

1455 try: 

1456 from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey 

1457 pub_key = Ed25519PublicKey.from_public_bytes(bytes.fromhex(public_key_hex)) 

1458 pub_key.verify( 

1459 bytes.fromhex(signature_hex), 

1460 bytes.fromhex(challenge_hex), 

1461 ) 

1462 except Exception: 

1463 return jsonify({'success': False, 'error': 'Invalid signature'}), 403 

1464 

1465 # Signature valid — upgrade to central 

1466 # Note: private key is NOT stored. Only the tier is persisted. 

1467 try: 

1468 try: 

1469 from core.platform_paths import get_db_dir 

1470 data_dir = get_db_dir() 

1471 except ImportError: 

1472 data_dir = os.path.join(os.path.expanduser('~'), 'Documents', 'Nunba', 'data') 

1473 os.makedirs(data_dir, exist_ok=True) 

1474 config_path = os.path.join(data_dir, 'node_config.json') 

1475 config = {} 

1476 if os.path.isfile(config_path): 

1477 with open(config_path) as f: 

1478 config = json.load(f) 

1479 config['tier'] = 'central' 

1480 config['upgraded_at'] = _time.strftime('%Y-%m-%dT%H:%M:%SZ', _time.gmtime()) 

1481 config['upgrade_method'] = 'challenge_response' 

1482 # DO NOT store master_key_hex — it never leaves the browser 

1483 config.pop('master_key_hex', None) 

1484 with open(config_path, 'w') as f: 

1485 json.dump(config, f, indent=2) 

1486 except Exception as e: 

1487 return jsonify({'success': False, 'error': f'Failed to persist tier: {e}'}), 500 

1488 

1489 os.environ['HEVOLVE_NODE_TIER'] = 'central' 

1490 os.environ['HEVOLVE_RESTART_REQUESTED'] = 'all' 

1491 os.environ['HEVOLVE_RESTART_REASON'] = 'Auto-upgrade to central via challenge-response proof' 

1492 

1493 # Generate HART central identity 

1494 try: 

1495 from hart_onboarding import generate_node_identity 

1496 generate_node_identity(tier='central') 

1497 except Exception: 

1498 pass 

1499 

1500 logger.info("Node auto-upgraded to central via challenge-response (key never transmitted)") 

1501 return jsonify({ 

1502 'success': True, 

1503 'message': 'Master key verified via challenge-response. Node upgrading to central. Restarting...', 

1504 'new_tier': 'central', 

1505 }) 

1506 

1507 

1508@discovery_bp.route('/api/social/hierarchy/inventory') 

1509def hierarchy_inventory(): 

1510 """Network-wide inventory of all nodes grouped by tier. 

1511 

1512 Returns counts, node details (name, IP/URL, HART tag, status, compute), 

1513 grouped by tier. Central-only endpoint for safety visibility. 

1514 """ 

1515 if not _IS_CENTRAL: 

1516 return jsonify({'success': False, 'error': 'Only central can view inventory'}), 403 

1517 

1518 from .models import get_db, PeerNode 

1519 db = get_db() 

1520 try: 

1521 all_nodes = db.query(PeerNode).filter( 

1522 PeerNode.status != 'dead' 

1523 ).order_by(PeerNode.tier, PeerNode.name).all() 

1524 

1525 inventory = { 

1526 'central': [], 'regional': [], 'flat': [], 'local': [], 

1527 } 

1528 counts = { 

1529 'central': 0, 'regional': 0, 'flat': 0, 'local': 0, 

1530 'total': 0, 'active': 0, 'stale': 0, 

1531 } 

1532 

1533 for node in all_nodes: 

1534 tier = node.tier or 'flat' 

1535 entry = { 

1536 'node_id': node.node_id, 

1537 'name': node.name, 

1538 'url': node.url, 

1539 'hart_tag': getattr(node, 'hart_tag', '') or '', 

1540 'status': node.status, 

1541 'tier': tier, 

1542 'dns_region': node.dns_region or '', 

1543 'compute': { 

1544 'cpu_cores': node.compute_cpu_cores, 

1545 'ram_gb': node.compute_ram_gb, 

1546 'gpu_count': node.compute_gpu_count, 

1547 }, 

1548 'users': { 

1549 'active': node.active_user_count or 0, 

1550 'max': node.max_user_capacity or 0, 

1551 }, 

1552 'certificate_verified': getattr(node, 'certificate_verified', False), 

1553 'last_seen': getattr(node, 'last_seen_at', None), 

1554 } 

1555 

1556 if tier in inventory: 

1557 inventory[tier].append(entry) 

1558 else: 

1559 inventory['flat'].append(entry) 

1560 

1561 counts['total'] += 1 

1562 counts[tier] = counts.get(tier, 0) + 1 

1563 if node.status == 'active': 

1564 counts['active'] += 1 

1565 elif node.status == 'stale': 

1566 counts['stale'] += 1 

1567 

1568 return jsonify({ 

1569 'success': True, 

1570 'counts': counts, 

1571 'inventory': inventory, 

1572 }) 

1573 except Exception as e: 

1574 return jsonify({'success': False, 'error': str(e)}), 500 

1575 finally: 

1576 db.close()