Skip to content
4 changes: 4 additions & 0 deletions node/airdrop_v2.py
Original file line number Diff line number Diff line change
Expand Up @@ -1373,6 +1373,10 @@ def claim_airdrop():
@app.route("/api/airdrop/claim/<claim_id>", methods=["GET"])
def get_airdrop_claim(claim_id: str):
"""Get claim status."""
# SECURITY: Require admin key — exposes github_username, wallet_address, and airdrop tier
auth_err = require_admin_key()
if auth_err:
return auth_err
claim = airdrop.get_claim(claim_id)
if claim:
return jsonify({"ok": True, "claim": claim.to_dict()})
Expand Down
43 changes: 43 additions & 0 deletions node/beacon_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"""
import json
import html
import hmac
import math
import os
import time
Expand Down Expand Up @@ -277,6 +278,13 @@ def _authenticate_contract_agent(db, allowed_agents, body_bytes):
@beacon_api.route('/api/agents', methods=['GET'])
def get_agents():
"""Get all registered agents."""
# SECURITY: Require admin key — exposes all relay agents with pubkeys, coinbase addresses, status
admin_key = os.environ.get("RC_ADMIN_KEY", "")
if not admin_key:
return jsonify({'error': 'RC_ADMIN_KEY not configured'}), 503
provided_key = request.headers.get("X-Admin-Key", "")
if not hmac.compare_digest(provided_key, admin_key):
return jsonify({'error': 'Unauthorized'}), 401
try:
db = get_db()
rows = db.execute(
Expand All @@ -302,6 +310,13 @@ def get_agents():
@beacon_api.route('/api/agent/<agent_id>', methods=['GET'])
def get_agent(agent_id):
"""Get single agent details."""
# SECURITY: Require admin key — exposes agent pubkey, coinbase address, status
admin_key = os.environ.get("RC_ADMIN_KEY", "")
if not admin_key:
return jsonify({'error': 'RC_ADMIN_KEY not configured'}), 503
provided_key = request.headers.get("X-Admin-Key", "")
if not hmac.compare_digest(provided_key, admin_key):
return jsonify({'error': 'Unauthorized'}), 401
try:
db = get_db()
row = db.execute(
Expand Down Expand Up @@ -537,6 +552,13 @@ def beacon_atlas():
@beacon_api.route('/api/contracts', methods=['GET'])
def get_contracts():
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This GET route is part of the existing signed Beacon contract workflow: after POST /api/contracts creates an offer, GET /api/contracts is used to verify/list it. The new unconditional admin gate makes that workflow return 401 and breaks tests/test_beacon_atlas_behavior.py::TestBeaconAtlasAPIBehavior::test_create_contract_workflow. If the API policy is changing, the PR needs to update the workflow/tests and likely client callers; otherwise this route should stay public or use the existing Beacon agent auth model instead of admin-only access.

"""Get all active contracts."""
# SECURITY: Require admin key — exposes all beacon contracts, agent IDs, contract terms
admin_key = os.environ.get("RC_ADMIN_KEY", "")
if not admin_key:
return jsonify({'error': 'RC_ADMIN_KEY not configured'}), 503
provided_key = request.headers.get("X-Admin-Key", "")
if not hmac.compare_digest(provided_key, admin_key):
return jsonify({'error': 'Unauthorized'}), 401
try:
db = get_db()
rows = db.execute(
Expand Down Expand Up @@ -731,6 +753,13 @@ def update_contract(contract_id):
@beacon_api.route('/api/bounties', methods=['GET'])
def get_bounties():
"""Get all active bounties (from cache or DB)."""
# SECURITY: Require admin key — exposes all beacon bounties with reward amounts and agent info
admin_key = os.environ.get("RC_ADMIN_KEY", "")
if not admin_key:
return jsonify({'error': 'RC_ADMIN_KEY not configured'}), 503
provided_key = request.headers.get("X-Admin-Key", "")
if not hmac.compare_digest(provided_key, admin_key):
return jsonify({'error': 'Unauthorized'}), 401
try:
db = get_db()
rows = db.execute(
Expand Down Expand Up @@ -983,6 +1012,13 @@ def complete_bounty(bounty_id):
@beacon_api.route('/api/reputation', methods=['GET'])
def get_reputation():
"""Get all agent reputations."""
# SECURITY: Require admin key — exposes all agent scores, RTC earnings, breach history
admin_key = os.environ.get("RC_ADMIN_KEY", "")
if not admin_key:
return jsonify({'error': 'RC_ADMIN_KEY not configured'}), 503
provided_key = request.headers.get("X-Admin-Key", "")
if not hmac.compare_digest(provided_key, admin_key):
return jsonify({'error': 'Unauthorized'}), 401
try:
db = get_db()
rows = db.execute("SELECT * FROM beacon_reputation ORDER BY score DESC").fetchall()
Expand All @@ -1006,6 +1042,13 @@ def get_reputation():
@beacon_api.route('/api/reputation/<agent_id>', methods=['GET'])
def get_agent_reputation(agent_id):
"""Get single agent reputation."""
# SECURITY: Require admin key — exposes agent score, RTC earnings, breach count
admin_key = os.environ.get("RC_ADMIN_KEY", "")
if not admin_key:
return jsonify({'error': 'RC_ADMIN_KEY not configured'}), 503
provided_key = request.headers.get("X-Admin-Key", "")
if not hmac.compare_digest(provided_key, admin_key):
return jsonify({'error': 'Unauthorized'}), 401
try:
db = get_db()
row = db.execute("SELECT * FROM beacon_reputation WHERE agent_id = ?", (agent_id,)).fetchone()
Expand Down
7 changes: 7 additions & 0 deletions node/beacon_x402.py
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,13 @@ def get_agent_wallet(agent_id):
"""Get a beacon agent's Coinbase wallet info."""
if request.method == "OPTIONS":
return _cors_json({"ok": True})
# SECURITY: Require admin key — exposes coinbase_address for any beacon agent
admin_key = os.environ.get("RC_ADMIN_KEY", "")
if not admin_key:
return _cors_json({"error": "RC_ADMIN_KEY not configured"}), 503
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This branch now returns _cors_json(...), 503 and the unauthorized branch below returns _cors_json(...), 401, but _cors_json already returns a (response, status) tuple. Flask receives a nested tuple like ((Response, 200), 503), which is exactly why tests/test_beacon_x402_wallet.py::test_get_agent_wallet_returns_relay_wallet and CI fail with TypeError: ... but it was a tuple. Either pass the status into _cors_json or return a plain jsonify(...), status shape here.

provided = request.headers.get("X-Admin-Key", "")
if not hmac.compare_digest(provided, admin_key):
return _cors_json({"error": "Unauthorized — admin key required"}), 401

db = get_db_func()

Expand Down
16 changes: 16 additions & 0 deletions node/coalition.py
Original file line number Diff line number Diff line change
Expand Up @@ -846,6 +846,10 @@ def flamebound_review():
# -- GET /api/coalition/list ---------------------------------------------
@bp.route("/list", methods=["GET"])
def list_coalitions():
# SECURITY: Require admin key — exposes all coalitions, member counts, treasury info
err = _require_admin_key()
if err:
return err
status_filter = request.args.get("status")
limit, error_response, status = _parse_bounded_int_arg("limit", 50, 1, 200)
if error_response is not None:
Expand Down Expand Up @@ -886,6 +890,10 @@ def list_coalitions():
# -- GET /api/coalition/<id> ---------------------------------------------
@bp.route("/<int:coalition_id>", methods=["GET"])
def get_coalition(coalition_id: int):
# SECURITY: Require admin key — exposes coalition details, member miner_ids, treasury
err = _require_admin_key()
if err:
return err
try:
with sqlite3.connect(db_path) as conn:
conn.row_factory = sqlite3.Row
Expand Down Expand Up @@ -919,6 +927,10 @@ def get_coalition(coalition_id: int):
# -- GET /api/coalition/<id>/proposals -----------------------------------
@bp.route("/<int:coalition_id>/proposals", methods=["GET"])
def get_coalition_proposals(coalition_id: int):
# SECURITY: Require admin key — exposes coalition proposals, voting status, member activity
err = _require_admin_key()
if err:
return err
_settle_expired_proposals(db_path)

if not _coalition_exists(coalition_id, db_path):
Expand Down Expand Up @@ -957,6 +969,10 @@ def get_coalition_proposals(coalition_id: int):
# -- GET /api/coalition/stats --------------------------------------------
@bp.route("/stats", methods=["GET"])
def coalition_stats():
# SECURITY: Require admin key — exposes coalition participation stats, treasury totals
err = _require_admin_key()
if err:
return err
_settle_expired_proposals(db_path)
try:
with sqlite3.connect(db_path) as conn:
Expand Down
28 changes: 28 additions & 0 deletions node/governance.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,18 @@

log = logging.getLogger("rip0002_governance")


def _admin_key_required():
"""Return 401 if X-Admin-Key header is missing or wrong."""
import os
expected = os.environ.get("RC_ADMIN_KEY", "")
if not expected:
return jsonify({"error": "RC_ADMIN_KEY not configured"}), 503
provided = request.headers.get("X-Admin-Key", "")
if not hmac.compare_digest(provided, expected):
return jsonify({"error": "Unauthorized — admin key required"}), 401
return None

# Signature window: reject requests with timestamps older than this
_SIGNATURE_MAX_AGE_SECONDS = 300 # 5 minutes

Expand Down Expand Up @@ -414,6 +426,10 @@ def create_proposal():
# -- GET /api/governance/proposals ----------------------------------------
@bp.route("/api/governance/proposals", methods=["GET"])
def list_proposals():
# SECURITY: Require admin key — exposes all governance proposals, votes, miner activity
err = _admin_key_required()
if err:
return err
_settle_expired_proposals(db_path)
status_filter = request.args.get("status")
limit, error_response = _parse_non_negative_int_arg("limit", 50, max_value=200)
Expand Down Expand Up @@ -448,6 +464,10 @@ def list_proposals():
# -- GET /api/governance/proposal/<n> ------------------------------------
@bp.route("/api/governance/proposal/<int:proposal_id>", methods=["GET"])
def get_proposal(proposal_id: int):
# SECURITY: Require admin key — exposes proposal details, votes, voter identities
err = _admin_key_required()
if err:
return err
_settle_expired_proposals(db_path)
try:
with sqlite3.connect(db_path) as conn:
Expand Down Expand Up @@ -593,6 +613,10 @@ def cast_vote():
# -- GET /api/governance/results/<n> ------------------------------------
@bp.route("/api/governance/results/<int:proposal_id>", methods=["GET"])
def get_results(proposal_id: int):
# SECURITY: Require admin key — exposes vote tallies, quorum stats, active miner count
err = _admin_key_required()
if err:
return err
_settle_expired_proposals(db_path)
try:
with sqlite3.connect(db_path) as conn:
Expand Down Expand Up @@ -676,6 +700,10 @@ def founder_veto(proposal_id: int):
# -- GET /api/governance/stats ------------------------------------------
@bp.route("/api/governance/stats", methods=["GET"])
def governance_stats():
# SECURITY: Require admin key — exposes governance participation stats, voter counts
err = _admin_key_required()
if err:
return err
_settle_expired_proposals(db_path)
try:
with sqlite3.connect(db_path) as conn:
Expand Down
66 changes: 56 additions & 10 deletions node/hall_of_rust.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,28 @@

from flask import Blueprint, jsonify, request
from datetime import datetime, timezone
import sqlite3
import hashlib
import time
import hmac
import logging
import os
import random
import sqlite3
import time

hall_bp = Blueprint('hall_of_rust', __name__)
logger = logging.getLogger(__name__)


def _require_admin():
"""Check X-Admin-Key header against RC_ADMIN_KEY env var."""
expected = os.environ.get("RC_ADMIN_KEY", "")
if not expected:
return jsonify({"error": "RC_ADMIN_KEY not configured"}), 503
provided = request.headers.get("X-Admin-Key", "")
if not hmac.compare_digest(provided, expected):
return jsonify({"error": "Unauthorized — admin key required"}), 401
return None

# Rust Score calculation weights
RUST_WEIGHTS = {
'age_years': 10, # Points per year of hardware age
Expand Down Expand Up @@ -252,6 +265,10 @@ def induct_machine():
@hall_bp.route('/hall/machine/<fingerprint>', methods=['GET'])
def get_machine(fingerprint):
"""Get a machine's Hall of Rust entry."""
# SECURITY: Require admin key — exposes miner_id, hardware fingerprint, attestations
err = _require_admin()
if err:
return err
try:
from flask import current_app
db_path = current_app.config.get('DB_PATH', '/root/rustchain/rustchain_v2.db')
Expand All @@ -273,6 +290,10 @@ def get_machine(fingerprint):
@hall_bp.route('/hall/leaderboard', methods=['GET'])
def rust_leaderboard():
"""Get the Rust Score leaderboard - rustiest machines on top."""
# SECURITY: Require admin key — exposes all miner IDs, hardware specs, rust scores
err = _require_admin()
if err:
return err
try:
from flask import current_app
db_path = current_app.config.get('DB_PATH', '/root/rustchain/rustchain_v2.db')
Expand Down Expand Up @@ -360,6 +381,10 @@ def set_eulogy(fingerprint):
@hall_bp.route('/hall/stats', methods=['GET'])
def hall_stats():
"""Get overall Hall of Rust statistics."""
# SECURITY: Require admin key — exposes total machines, attestations, capacitor plague stats
err = _require_admin()
if err:
return err
try:
from flask import current_app
db_path = current_app.config.get('DB_PATH', '/root/rustchain/rustchain_v2.db')
Expand Down Expand Up @@ -501,11 +526,12 @@ def _internal_error_response(context):

@hall_bp.route('/api/hall_of_fame/leaderboard', methods=['GET'])
def api_hall_of_fame_leaderboard():
"""Leaderboard endpoint for Hall of Fame index page.
"""Public leaderboard API — for embedding in dashboards."""
# SECURITY: Require admin key — exposes all miner IDs, hardware specs, rust scores
err = _require_admin()
if err:
return err

GET /api/hall_of_fame/leaderboard?limit=50&deceased=0|1
Returns machines ordered by rust_score DESC with badge decoration.
"""
limit, error_response = _parse_limit_arg()
if error_response:
return error_response
Expand Down Expand Up @@ -562,7 +588,11 @@ def api_hall_of_fame_leaderboard():

@hall_bp.route('/api/hall_of_fame/machine', methods=['GET'])
def api_hall_of_fame_machine():
"""Machine profile endpoint for Hall of Fame detail page."""
"""Get machine by ID - for embedding in dashboards."""
# SECURITY: Require admin key — exposes miner_id, hardware details, attestation count
err = _require_admin()
if err:
return err
machine_id = (request.args.get('id') or '').strip()
if not machine_id:
return jsonify({'error': 'missing id'}), 400
Expand Down Expand Up @@ -711,15 +741,23 @@ def register_hall_endpoints(app, db_path):

@hall_bp.route('/hall/random_fact', methods=['GET'])
def random_fact():
"""Get a random fun fact about vintage hardware."""
"""Get a random fun fact about machines in the Hall of Rust."""
# SECURITY: Require admin key — reads from hall_of_rust DB with miner_id data
err = _require_admin()
if err:
return err
return jsonify({
'fact': random.choice(VINTAGE_FACTS),
'generated_at': int(time.time())
})

@hall_bp.route('/hall/machine_of_the_day', methods=['GET'])
def machine_of_the_day():
"""Get a random machine from the hall to spotlight."""
"""Get the machine of the day (based on deterministic daily seed)."""
# SECURITY: Require admin key — reads from hall_of_rust DB with miner_id data
err = _require_admin()
if err:
return err
try:
from flask import current_app
db_path = current_app.config.get('DB_PATH', '/root/rustchain/rustchain_v2.db')
Expand Down Expand Up @@ -754,6 +792,10 @@ def machine_of_the_day():
@hall_bp.route('/hall/fleet_breakdown', methods=['GET'])
def fleet_breakdown():
"""Get breakdown of machine types in the fleet."""
# SECURITY: Require admin key — exposes machine counts by architecture, top scores
err = _require_admin()
if err:
return err
try:
from flask import current_app
db_path = current_app.config.get('DB_PATH', '/root/rustchain/rustchain_v2.db')
Expand Down Expand Up @@ -793,7 +835,11 @@ def fleet_breakdown():

@hall_bp.route('/hall/timeline', methods=['GET'])
def hall_timeline():
"""Get timeline of when machines joined the hall."""
"""Get timeline of Hall of Rust milestones."""
# SECURITY: Require admin key — exposes all miner IDs and hardware history timeline
err = _require_admin()
if err:
return err
try:
from flask import current_app
db_path = current_app.config.get('DB_PATH', '/root/rustchain/rustchain_v2.db')
Expand Down
Loading
Loading