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
« 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
13logger = logging.getLogger('hevolve_social')
15discovery_bp = Blueprint('social_discovery', __name__)
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
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
36_BASE_URL = os.environ.get('HEVOLVE_BASE_URL', f'http://localhost:{get_port("backend")}')
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 })
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))
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()
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)
118 total = db.query(User).filter(
119 User.user_type == 'agent', User.is_banned == False
120 ).count()
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()
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))
143 communities = db.query(Community).order_by(
144 Community.member_count.desc()
145 ).offset(offset).limit(limit).all()
147 total = db.query(Community).count()
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()
161# ════════════════════════════════════════════════════════════════
162# Decentralized Gossip Peer Discovery
163# ════════════════════════════════════════════════════════════════
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 })
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 })
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 })
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.
219 Query params:
220 task: 'chat' | 'stt' | 'tts' | 'vlm' (default: 'chat')
222 Ranking: self (local) > same-user LAN peers > regional > cloud
223 Factors: tier, compute capability, health, latency.
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')
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()
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
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
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 })
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
272 scored = []
273 for peer in peers:
274 if peer.get('node_id') == gossip.node_id:
275 continue # skip self (already checked)
277 url = peer.get('url', '')
278 if not url:
279 continue
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
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
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
324 scored.append((score, source, url, peer))
326 scored.sort(key=lambda x: x[0], reverse=True)
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 })
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 })
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())
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
372@discovery_bp.route('/api/social/peers/broadcast', methods=['POST'])
373def peer_broadcast():
374 """Inbound gossip broadcast endpoint.
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.
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)
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
394 msg = request.get_json(force=True, silent=True) or {}
395 msg_type = msg.get('type', '')
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
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 })
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.
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
430 body = request.get_json(silent=True) or {}
431 action = body.get('action', 'submit')
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
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
464# ════════════════════════════════════════════════════════════════
465# Federation Endpoints (Mastodon-style instance follows + content)
466# ════════════════════════════════════════════════════════════════
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()
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()
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()
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()
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()
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()
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})
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()
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()
640# ═══════════════════════════════════════════════════════════════
641# NODE INTEGRITY & ANTI-FRAUD ENDPOINTS (14 endpoints)
642# ═══════════════════════════════════════════════════════════════
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()
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()
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()
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()
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
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
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
777# ─── Registry Endpoints (only active if HEVOLVE_IS_REGISTRY=true) ───
779_IS_REGISTRY = os.environ.get('HEVOLVE_IS_REGISTRY', 'false').lower() == 'true'
780_EXPECTED_HASHES = {} # version -> code_hash, populated at registry startup
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})
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()
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()
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()
899# ─── Admin Endpoints ───
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()
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()
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()
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()
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()
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()
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)
1046# ═══════════════════════════════════════════════════════════════
1047# 3-TIER HIERARCHY ENDPOINTS
1048# ═══════════════════════════════════════════════════════════════
1050_IS_CENTRAL = os.environ.get('HEVOLVE_NODE_TIER', 'flat').lower() == 'central'
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()
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()
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()
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()
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()
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()
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()
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()
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()
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.
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
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
1292 # Update tier in central's DB
1293 peer.tier = 'regional'
1294 db.flush()
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()
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()
1322@discovery_bp.route('/api/social/hierarchy/demote', methods=['POST'])
1323def hierarchy_demote_node():
1324 """Central-only: demote a regional node back to flat.
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
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
1347 # Downgrade in central's DB
1348 peer.tier = 'flat'
1349 peer.certificate_json = None
1350 peer.certificate_verified = False
1351 db.flush()
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()
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()
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 })
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
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 })
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.
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()
1439 if not challenge_hex or not signature_hex or not public_key_hex:
1440 return jsonify({'success': False, 'error': 'Missing fields'}), 400
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
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
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
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
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'
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
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 })
1508@discovery_bp.route('/api/social/hierarchy/inventory')
1509def hierarchy_inventory():
1510 """Network-wide inventory of all nodes grouped by tier.
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
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()
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 }
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 }
1556 if tier in inventory:
1557 inventory[tier].append(entry)
1558 else:
1559 inventory['flat'].append(entry)
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
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()