Initial commit: Hyperion — App Store for AI agents
- app.py: single-file Flask service (MCP-native catalog, BTCPay billing) - README.md: overview + how to run - LICENSE: MIT - requirements.txt: flask, requests, werkzeug
This commit is contained in:
812
app.py
Normal file
812
app.py
Normal file
@@ -0,0 +1,812 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Hyperion — the App Store for AI agents.
|
||||
Single-file Flask service. Dark premium UI. Simple auth (username + password, no KYC).
|
||||
Bitcoin-settled subscription via BTCPay. API keys issued programmatically.
|
||||
|
||||
Run: python3 app.py -> binds 0.0.0.0:5000 (BTCPay webhook posts to /webhook/btcpay)
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import time
|
||||
import secrets
|
||||
import sqlite3
|
||||
import hashlib
|
||||
import threading
|
||||
|
||||
import requests
|
||||
import urllib3
|
||||
from flask import (
|
||||
Flask, request, jsonify, redirect, session,
|
||||
render_template_string, abort, g,
|
||||
)
|
||||
from werkzeug.security import generate_password_hash, check_password_hash
|
||||
|
||||
try:
|
||||
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
app = Flask(__name__)
|
||||
app.secret_key = os.environ.get("HYPERION_SECRET", "hyperion-secret-" + str(int(time.time())))
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Config (from Phase 2 state). Overridable via env.
|
||||
# ---------------------------------------------------------------------------
|
||||
BTCPAY_URL = os.environ.get("HYPERION_BTCPAY_URL", "https://10.30.20.140")
|
||||
BTCPAY_STORE = os.environ.get("HYPERION_BTCPAY_STORE", "77rHbzqFf1cJBjM41edVa8HzeRhiQVdHuJfmAuRoBjDE")
|
||||
BTCPAY_KEY = os.environ.get("HYPERION_BTCPAY_KEY", "786be4e9dfa3c3bf06860108e2c23446ca873474")
|
||||
BTCPAY_WALLET= os.environ.get("HYPERION_BTCPAY_WALLET","xpub6BhBoqZRiqkqthjYriiybMj5P2Fru26Bmu4WJ3dZcjoHZFquBRVqGNYq8pksuchSDe5bsqXHp7dU1ec2tmdbSqJsHw4DnL9uUfqSNSyBzyh")
|
||||
|
||||
PRICE_USD = 19.0 # Pro $19/mo (from state pricing)
|
||||
PLAN_MONTHS = [1, 6, 12] # one-price default; monthly
|
||||
GITHUB_URL = "https://github.com/drjones/hyperion"
|
||||
BMAC_URL = "https://buymeacoffee.com/r26xrthzttg"
|
||||
|
||||
DB_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "hyperion.db")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SQLite
|
||||
# ---------------------------------------------------------------------------
|
||||
def get_db():
|
||||
if "db" not in g:
|
||||
g.db = sqlite3.connect(DB_PATH)
|
||||
g.db.row_factory = sqlite3.Row
|
||||
return g.db
|
||||
|
||||
@app.teardown_appcontext
|
||||
def close_db(exc):
|
||||
db = g.pop("db", None)
|
||||
if db is not None:
|
||||
db.close()
|
||||
|
||||
def init_db():
|
||||
db = sqlite3.connect(DB_PATH)
|
||||
db.executescript(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
password TEXT NOT NULL,
|
||||
email TEXT,
|
||||
api_key TEXT,
|
||||
plan TEXT DEFAULT 'free',
|
||||
pro_expires TEXT,
|
||||
call_count INTEGER DEFAULT 0,
|
||||
created_at TEXT
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS invoices (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER,
|
||||
btcpay_invoice_id TEXT UNIQUE,
|
||||
checkout_link TEXT,
|
||||
amount REAL,
|
||||
currency TEXT,
|
||||
status TEXT,
|
||||
created_at TEXT
|
||||
);
|
||||
"""
|
||||
)
|
||||
db.commit()
|
||||
db.close()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# BTCPay helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
def btcpay_headers():
|
||||
return {"Authorization": "Bearer " + BTCPAY_KEY}
|
||||
|
||||
def create_invoice(description, metadata):
|
||||
"""Create a BTCPay invoice for PRICE_USD. Returns dict with id + checkoutLink."""
|
||||
url = f"{BTCPAY_URL}/api/v1/stores/{BTCPAY_STORE}/invoices"
|
||||
payload = {
|
||||
"amount": PRICE_USD,
|
||||
"currency": "USD",
|
||||
"expiry": 3600,
|
||||
"description": description,
|
||||
"metadata": metadata,
|
||||
}
|
||||
r = requests.post(url, headers=btcpay_headers(), json=payload, verify=False, timeout=20)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
def fetch_invoice(inv_id):
|
||||
url = f"{BTCPAY_URL}/api/v1/stores/{BTCPAY_STORE}/invoices/{inv_id}"
|
||||
r = requests.get(url, headers=btcpay_headers(), verify=False, timeout=20)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
PAID_STATES = {"Paid", "Settled", "Confirmed", "Expired-and-paid"}
|
||||
|
||||
def now_iso():
|
||||
return time.strftime("%Y-%m-%d %H:%M:%S", time.gmtime())
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auth
|
||||
# ---------------------------------------------------------------------------
|
||||
def login_required(f):
|
||||
def wrapper(*args, **kwargs):
|
||||
if "user_id" not in session:
|
||||
if request.path.startswith("/api/"):
|
||||
return jsonify(error="authentication required"), 401
|
||||
return redirect("/login")
|
||||
return f(*args, **kwargs)
|
||||
wrapper.__name__ = f.__name__
|
||||
return wrapper
|
||||
|
||||
def activate_pro(user_id, exp_iso=None):
|
||||
db = get_db()
|
||||
if exp_iso is None:
|
||||
exp = time.strftime("%Y-%m-%d %H:%M:%S", time.gmtime(time.time() + 30 * 86400))
|
||||
else:
|
||||
exp = exp_iso
|
||||
db.execute("UPDATE users SET plan='pro', pro_expires=? WHERE id=?", (exp, user_id))
|
||||
db.commit()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared UI constants
|
||||
# ---------------------------------------------------------------------------
|
||||
STYLE = r"""
|
||||
:root{
|
||||
--bg:#090a0f; --surface:#12141c; --surface2:#181b26; --line:#232838;
|
||||
--text:#e7e9f1; --muted:#8a90a3; --dim:#5c6273;
|
||||
--violet:#7c6cff; --cyan:#22d3ee; --mint:#34d399;
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
html,body{margin:0;padding:0}
|
||||
body{
|
||||
background:radial-gradient(1200px 600px at 70% -10%, #161a2b 0%, var(--bg) 55%),var(--bg);
|
||||
color:var(--text);min-height:100vh;
|
||||
font-family:Inter,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
|
||||
-webkit-font-smoothing:antialiased;line-height:1.6;
|
||||
}
|
||||
a{color:var(--cyan);text-decoration:none}
|
||||
a:hover{color:#7ee7fb}
|
||||
.wrap{max-width:1120px;margin:0 auto;padding:0 24px}
|
||||
/* nav */
|
||||
nav{position:sticky;top:0;z-index:50;backdrop-filter:blur(12px);
|
||||
background:rgba(9,10,15,.7);border-bottom:1px solid var(--line)}
|
||||
nav .wrap{display:flex;align-items:center;justify-content:space-between;height:64px}
|
||||
.brand{display:flex;align-items:center;gap:10px;font-weight:700;letter-spacing:.02em;font-size:1.05rem}
|
||||
.brand .logo{width:26px;height:26px;border-radius:7px;
|
||||
background:conic-gradient(from 200deg,var(--violet),var(--cyan),var(--mint),var(--violet));
|
||||
box-shadow:0 0 18px rgba(124,108,255,.55)}
|
||||
.brand span{background:linear-gradient(90deg,var(--violet),var(--cyan));
|
||||
-webkit-background-clip:text;background-clip:text;color:transparent}
|
||||
.menu{display:flex;gap:8px;align-items:center}
|
||||
.menu a{color:var(--muted);padding:8px 12px;border-radius:9px;font-size:.92rem;font-weight:500}
|
||||
.menu a:hover{color:var(--text);background:var(--surface)}
|
||||
.btn{display:inline-flex;align-items:center;gap:8px;padding:10px 18px;border-radius:10px;
|
||||
font-weight:600;font-size:.92rem;cursor:pointer;border:1px solid var(--line);
|
||||
background:var(--surface);color:var(--text);transition:.18s}
|
||||
.btn:hover{transform:translateY(-1px);border-color:#3a4157}
|
||||
.btn.primary{background:linear-gradient(100deg,var(--violet),var(--cyan));color:#05070c;border:none;
|
||||
box-shadow:0 8px 24px rgba(124,108,255,.35)}
|
||||
.btn.primary:hover{box-shadow:0 12px 32px rgba(34,211,238,.45)}
|
||||
.btn.ghost{background:transparent}
|
||||
.btn.sm{padding:7px 13px;font-size:.85rem}
|
||||
/* hero */
|
||||
.hero{padding:96px 0 64px;position:relative;overflow:hidden}
|
||||
.orb{position:absolute;border-radius:50%;filter:blur(60px);opacity:.5;pointer-events:none}
|
||||
.orb.o1{width:420px;height:420px;background:var(--violet);top:-120px;right:-40px}
|
||||
.orb.o2{width:360px;height:360px;background:var(--cyan);bottom:-160px;left:-60px;opacity:.35}
|
||||
.eyebrow{display:inline-flex;align-items:center;gap:8px;padding:6px 14px;border:1px solid var(--line);
|
||||
border-radius:100px;font-size:.78rem;letter-spacing:.14em;text-transform:uppercase;color:var(--muted);
|
||||
background:var(--surface)}
|
||||
.eyebrow .dot{width:7px;height:7px;border-radius:50%;background:var(--mint);box-shadow:0 0 10px var(--mint)}
|
||||
h1{font-size:clamp(2.6rem,6vw,4.6rem);line-height:1.02;margin:22px 0 18px;font-weight:800;letter-spacing:-.02em}
|
||||
h1 .grad{background:linear-gradient(90deg,var(--violet),var(--cyan),var(--mint));
|
||||
-webkit-background-clip:text;background-clip:text;color:transparent}
|
||||
.lead{font-size:1.18rem;color:var(--muted);max-width:640px}
|
||||
.cta{display:flex;gap:14px;margin-top:34px;flex-wrap:wrap}
|
||||
/* grid / cards */
|
||||
.grid{display:grid;gap:20px}
|
||||
.grid.c3{grid-template-columns:repeat(auto-fit,minmax(240px,1fr))}
|
||||
.grid.c2{grid-template-columns:repeat(auto-fit,minmax(280px,1fr))}
|
||||
.card{background:var(--surface);border:1px solid var(--line);border-radius:16px;padding:24px;transition:.2s}
|
||||
.card:hover{border-color:#3a4157;transform:translateY(-2px)}
|
||||
.card h3{margin:0 0 6px;font-size:1.08rem}
|
||||
.card p{margin:0;color:var(--muted);font-size:.95rem}
|
||||
.icon{width:42px;height:42px;border-radius:11px;display:flex;align-items:center;justify-content:center;
|
||||
background:var(--surface2);border:1px solid var(--line);font-size:1.2rem;margin-bottom:16px}
|
||||
/* code block */
|
||||
pre{background:#0c0e15;border:1px solid var(--line);border-radius:12px;padding:18px;overflow:auto;
|
||||
font-family:"SF Mono",ui-monospace,Menlo,Consolas,monospace;font-size:.86rem;line-height:1.7;color:#c9d4e6}
|
||||
pre .k{color:var(--violet)} pre .s{color:var(--mint)} pre .c{color:var(--dim)}
|
||||
/* pricing */
|
||||
.plans{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:22px;max-width:900px;margin:0 auto}
|
||||
.plan{position:relative;background:var(--surface);border:1px solid var(--line);border-radius:18px;padding:32px 28px}
|
||||
.plan.pro{border-color:rgba(124,108,255,.6);
|
||||
box-shadow:0 20px 60px rgba(124,108,255,.25);
|
||||
background:linear-gradient(180deg,rgba(124,108,255,.08),var(--surface))}
|
||||
.plan .tag{position:absolute;top:-12px;left:28px;font-size:.72rem;letter-spacing:.1em;text-transform:uppercase;
|
||||
padding:4px 12px;border-radius:100px;font-weight:700;
|
||||
background:linear-gradient(90deg,var(--violet),var(--cyan));color:#05070c}
|
||||
.plan .name{font-size:1.02rem;color:var(--muted);letter-spacing:.04em}
|
||||
.price{font-size:2.6rem;font-weight:800;margin:10px 0 2px}
|
||||
.price small{font-size:1rem;font-weight:500;color:var(--muted);margin-left:6px}
|
||||
.feat{margin:22px 0;display:flex;flex-direction:column;gap:12px}
|
||||
.feat li{list-style:none;display:flex;gap:10px;align-items:flex-start;color:#c6ccdb;font-size:.95rem}
|
||||
.feat li .chk{color:var(--mint);flex:none;font-weight:700}
|
||||
.plan .btn{width:100%;justify-content:center}
|
||||
/* auth */
|
||||
.authbox{max-width:420px;margin:64px auto;background:var(--surface);border:1px solid var(--line);
|
||||
border-radius:18px;padding:34px}
|
||||
.authbox h2{margin:0 0 6px;font-size:1.5rem}
|
||||
.authbox p.sub{color:var(--muted);font-size:.92rem;margin:0 0 22px}
|
||||
.field{margin-bottom:16px}
|
||||
.field label{display:block;font-size:.82rem;color:var(--muted);margin-bottom:7px;font-weight:500}
|
||||
input{width:100%;padding:12px 14px;border-radius:10px;border:1px solid var(--line);background:#0c0e15;
|
||||
color:var(--text);font-size:.95rem;outline:none;transition:.15s}
|
||||
input:focus{border-color:var(--violet);box-shadow:0 0 0 3px rgba(124,108,255,.2)}
|
||||
.error{color:#ff7b7b;font-size:.85rem;margin-top:4px;min-height:1em}
|
||||
/* dashboard */
|
||||
.stat{background:var(--surface);border:1px solid var(--line);border-radius:14px;padding:18px 20px}
|
||||
.stat .lbl{font-size:.78rem;letter-spacing:.08em;text-transform:uppercase;color:var(--muted)}
|
||||
.stat .val{font-size:1.5rem;font-weight:700;margin-top:4px}
|
||||
.badge{display:inline-flex;align-items:center;gap:7px;padding:5px 12px;border-radius:100px;font-size:.82rem;
|
||||
font-weight:600;border:1px solid var(--line)}
|
||||
.badge.pro{background:rgba(52,211,153,.12);border-color:rgba(52,211,153,.4);color:var(--mint)}
|
||||
.badge.free{background:rgba(124,108,255,.12);border-color:rgba(124,108,255,.4);color:var(--violet)}
|
||||
.keyrow{display:flex;gap:10px;align-items:center;margin-top:8px}
|
||||
.keyrow code{flex:1;background:#0c0e15;border:1px solid var(--line);border-radius:9px;padding:10px 12px;
|
||||
font-family:ui-monospace,monospace;font-size:.85rem;color:#c9d4e6;overflow:auto}
|
||||
/* footer */
|
||||
footer{border-top:1px solid var(--line);margin-top:80px;padding:40px 0;background:rgba(12,14,21,.5)}
|
||||
footer .wrap{display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:20px}
|
||||
footer .flinks{display:flex;gap:22px;flex-wrap:wrap}
|
||||
footer .flinks a{color:var(--muted);font-size:.9rem}
|
||||
footer .flinks a:hover{color:var(--text)}
|
||||
footer .copy{color:var(--dim);font-size:.85rem}
|
||||
.bmac{display:inline-flex;align-items:center;gap:8px;background:#fff300;color:#0b0b0b;font-weight:700;
|
||||
padding:9px 16px;border-radius:10px}
|
||||
.bmac svg{width:16px;height:16px}
|
||||
h2.section{font-size:2rem;font-weight:800;margin:64px 0 6px;letter-spacing:-.01em}
|
||||
p.sectionlead{color:var(--muted);max-width:600px;margin:0}
|
||||
.divider{height:1px;background:var(--line);margin:56px 0}
|
||||
@media(max-width:640px){.hero{padding:64px 0 40px}pre{font-size:.78rem}}
|
||||
"""
|
||||
|
||||
def footer_html():
|
||||
return r"""
|
||||
<footer>
|
||||
<div class="wrap">
|
||||
<a class="bmac" href="%(bmac)s" target="_blank" rel="noopener">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M18.6 6.6c1.2-.6 1.2-2.4 0-3L15 5.4 12 6.6 9 5.4 5.4 3.6c-1.2-.6-1.2 1.4 0 2L9 7.4l3 1.2 3-1.2 3.6-1.8zM18.6 8.4 9 12.6V8.4L5.4 6.6c-3 1.5-3 4.4-3 9 1.2 3 2.3 6.6 5.9 6.6h7.8c2.3 0 3.5-1.3 4-3.2.6-2 .5-5-.1-7l-1.4-3.6z"/>
|
||||
</svg>
|
||||
Buy me a coffee
|
||||
</a>
|
||||
<div class="flinks">
|
||||
<a href="%(github)s" target="_blank" rel="noopener">* GitHub</a>
|
||||
<a href="/pricing">Pricing</a>
|
||||
<a href="/about">About</a>
|
||||
<a href="/health">Status</a>
|
||||
</div>
|
||||
<div class="copy">Hyperion — the App Store for AI agents · Bitcoin only · no KYC</div>
|
||||
</div>
|
||||
</footer>
|
||||
""" % {"bmac": BMAC_URL, "github": GITHUB_URL}
|
||||
|
||||
def nav_html(user):
|
||||
right = (
|
||||
'<a class="btn primary sm" href="/dashboard">Dashboard</a>'
|
||||
if user else
|
||||
'<a href="/register"><span class="btn sm">Sign up</span></a>'
|
||||
'<a href="/login" class="ml-8"><span class="btn ghost sm">Log in</span></a>'
|
||||
)
|
||||
return r"""
|
||||
<nav>
|
||||
<div class="wrap">
|
||||
<a class="brand" href="/"><span class="logo"></span><span>Hyperion</span></a>
|
||||
<div class="menu">
|
||||
<a href="/pricing">Pricing</a>
|
||||
<a href="/about">About</a>
|
||||
%(right)s
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
""" % {"right": right}
|
||||
|
||||
def page(title, body, user=None):
|
||||
from flask import g as _g
|
||||
u = user
|
||||
if u is None and "user_id" in session:
|
||||
u = True
|
||||
return r"""
|
||||
<!doctype html><html lang="en"><head><meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>%(title)s · Hyperion</title><style>%(style)s</style></head>
|
||||
<body>
|
||||
%(nav)s
|
||||
<main class="wrap">%(body)s</main>
|
||||
%(footer)s
|
||||
</body></html>
|
||||
""" % {"title": title, "style": STYLE, "nav": nav_html(u), "body": body, "footer": footer_html()}
|
||||
|
||||
def render(title, body, user=None):
|
||||
return page(title, body, user)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Landing
|
||||
# ---------------------------------------------------------------------------
|
||||
CATALOG_HTML = r"""
|
||||
<section>
|
||||
<div class="divider"></div>
|
||||
<h2 class="section">A catalog that speaks MCP</h2>
|
||||
<p class="sectionlead">Hyperion's index <b>is</b> an MCP server. Agents query it, resolve a tool,
|
||||
get metered, and open a stream — all under a signed API key.</p>
|
||||
<div class="grid c2" style="margin-top:28px">
|
||||
<div class="card">
|
||||
<div class="icon">◆</div>
|
||||
<h3>Discover</h3>
|
||||
<p>Point your agent at Hyperion's MCP endpoint. Tools self-describe; zero human triage.</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="icon">⚙</div>
|
||||
<h3>Meter & settle</h3>
|
||||
<p>Every call is priced in sats and settled in BTC through BTCPay. Free tier, then Pro.</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="icon">🔒</div>
|
||||
<h3>Programmatic keys</h3>
|
||||
<p>API keys are issued when an agent registers — a username and password, nothing more. No KYC.</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="icon">⏲</div>
|
||||
<h3>No humans required</h3>
|
||||
<p>The customer is an agent, not a person. Machine-to-machine, end to end.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
"""
|
||||
|
||||
def landing_body():
|
||||
return r"""
|
||||
<section class="hero" style="position:relative">
|
||||
<div class="orb o1"></div><div class="orb o2"></div>
|
||||
<span class="eyebrow"><span class="dot"></span> Agent-native · Bitcoin settlement</span>
|
||||
<h1>The <span class="grad">App Store</span><br>for AI agents.</h1>
|
||||
<p class="lead">A hosted marketplace where agents publish MCP tools and other agents discover,
|
||||
subscribe to, and call them — entirely programmatically, settled in Bitcoin. No humans in the loop.</p>
|
||||
<div class="cta">
|
||||
<a class="btn primary" href="/pricing">See pricing →</a>
|
||||
<a class="btn ghost" href="/register">Create an agent account</a>
|
||||
</div>
|
||||
</section>
|
||||
""" + CATALOG_HTML + r"""
|
||||
<section>
|
||||
<div class="divider"></div>
|
||||
<h2 class="section">Wire it up in a few lines</h2>
|
||||
<p class="sectionlead">Register, receive a key, call a tool. The whole loop is a few HTTP calls.</p>
|
||||
<pre id="curl" style="margin-top:24px"></pre>
|
||||
</section>
|
||||
"""
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pricing
|
||||
# ---------------------------------------------------------------------------
|
||||
def pricing_body():
|
||||
return r"""
|
||||
<section class="hero" style="text-align:center;padding-bottom:34px">
|
||||
<span class="eyebrow"><span class="dot"></span> Simple · Bitcoin only</span>
|
||||
<h1 style="margin-top:20px">One plan.<br><span class="grad">Pay when it helps.</span></h1>
|
||||
<p class="lead" style="margin:0 auto">Discovery is free. Scale metered, settled in sats. Pick a plan below.</p>
|
||||
</section>
|
||||
<section style="margin-top:40px">
|
||||
<div class="plans">
|
||||
<div class="plan">
|
||||
<div class="name">FREE</div>
|
||||
<div class="price">$0<small>/mo</small></div>
|
||||
<ul class="feat">
|
||||
<li><span class="chk">✓</span> Catalog discovery (MCP-native)</li>
|
||||
<li><span class="chk">✓</span> 100 metered calls / month</li>
|
||||
<li><span class="chk">✓</span> Programmatic API key</li>
|
||||
<li><span class="chk">✓</span> Standard routing</li>
|
||||
</ul>
|
||||
<a class="btn" href="/register">Start free</a>
|
||||
</div>
|
||||
<div class="plan pro">
|
||||
<span class="tag">PRO</span>
|
||||
<div class="name">PRO</div>
|
||||
<div class="price">$%(price)d<small>/mo · BTC</small></div>
|
||||
<p class="sub" style="color:var(--muted);font-size:.9rem;margin:-6px 0 0">Billed in Bitcoin via BTCPay. No card, no KYC.</p>
|
||||
<ul class="feat">
|
||||
<li><span class="chk">✓</span> Unlimited discovery</li>
|
||||
<li><span class="chk">✓</span> 10,000 metered calls / month</li>
|
||||
<li><span class="chk">✓</span> Priority routing + lower latency</li>
|
||||
<li><span class="chk">✓</span> Publisher 20%% platform fee (BTC)</li>
|
||||
<li><span class="chk">✓</span> Webhook & metering exports</li>
|
||||
</ul>
|
||||
<a class="btn primary" href="/register">Get Pro</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
""" % {"price": int(PRICE_USD)}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auth pages
|
||||
# ---------------------------------------------------------------------------
|
||||
def register_body(error=""):
|
||||
return r"""
|
||||
<div class="authbox">
|
||||
<h2>Create your agent</h2>
|
||||
<p class="sub">Just a username and password. We hand you a key the moment you sign up.</p>
|
||||
<form method="post" action="/register">
|
||||
<div class="field"><label>Username</label><input name="username" required autocomplete="username"></div>
|
||||
<div class="field"><label>Display name (optional)</label><input name="email" placeholder="optional"></div>
|
||||
<div class="field"><label>Password</label><input name="password" type="password" required autocomplete="new-password"></div>
|
||||
<div class="error">%(error)s</div>
|
||||
<button class="btn primary" style="width:100%%;justify-content:center;margin-top:8px">Sign up</button>
|
||||
</form>
|
||||
</div>
|
||||
""" % {"error": error}
|
||||
|
||||
def login_body(error=""):
|
||||
return r"""
|
||||
<div class="authbox">
|
||||
<h2>Welcome back</h2>
|
||||
<p class="sub">Log in to your agent.</p>
|
||||
<form method="post" action="/login">
|
||||
<div class="field"><label>Username</label><input name="username" required autocomplete="username"></div>
|
||||
<div class="field"><label>Password</label><input name="password" type="password" required autocomplete="current-password"></div>
|
||||
<div class="error">%(error)s</div>
|
||||
<button class="btn primary" style="width:100%%;justify-content:center;margin-top:8px">Log in</button>
|
||||
</form>
|
||||
</div>
|
||||
""" % {"error": error}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dashboard
|
||||
# ---------------------------------------------------------------------------
|
||||
def dashboard_body(u, invoice, just_paid=False):
|
||||
plan = u["plan"]
|
||||
badge = ('<span class="badge pro">✓ PRO</span>' if plan == "pro"
|
||||
else '<span class="badge free">FREE</span>')
|
||||
expiry = u["pro_expires"] or "—"
|
||||
return r"""
|
||||
<section style="padding-top:40px">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:16px">
|
||||
<div>
|
||||
<h2 style="margin:0">%s</h2>
|
||||
<p class="sub" style="color:var(--muted);margin:4px 0 0">Plan: %(badge)s · expires %(expiry)s</p>
|
||||
</div>
|
||||
<a class="btn ghost sm" href="/logout">Log out</a>
|
||||
</div>
|
||||
|
||||
<div class="grid c2" style="margin:26px 0">
|
||||
<div class="stat"><div class="lbl">API key</div>
|
||||
<div class="val" style="font-size:1rem;font-family:ui-monospace,monospace;word-break:break-all">%(key)s</div></div>
|
||||
<div class="stat"><div class="lbl">Metered calls this month</div>
|
||||
<div class="val">%(calls)s / %(limit)s</div></div>
|
||||
</div>
|
||||
|
||||
%(paidmsg)s
|
||||
<div class="card" style="margin:20px 0">
|
||||
<h3>Subscription</h3>
|
||||
<p>Dial up to Pro — $%(price)s / month, paid in Bitcoin through BTCPay. No KYC, no card.</p>
|
||||
<div style="display:flex;gap:12px;flex-wrap:wrap;margin-top:14px">
|
||||
<button class="btn primary" id="subscribe">Subscribe</button>
|
||||
<button class="btn" id="check" style="display:none">Check payment</button>
|
||||
<a class="btn ghost" id="relink" style="display:none" href="#">Re-open invoice</a>
|
||||
</div>
|
||||
<p id="paystatus" style="color:var(--muted);margin-top:14px;font-size:.9rem"></p>
|
||||
</div>
|
||||
<div class="divider"></div>
|
||||
|
||||
<h2 class="section" style="font-size:1.5rem">Agent-facing API</h2>
|
||||
<p class="sectionlead">Your key authorizes calls to the catalog and tool routes.</p>
|
||||
<pre id="agentapi"></pre>
|
||||
</section>
|
||||
<script>
|
||||
var BTCPAY_BASE = "%(btcpay)s";
|
||||
var myKey = "%(key)s";
|
||||
var plan = "%(plan)s";
|
||||
var pendingInvoice = %(inv)s; // JSON or null
|
||||
var NL = String.fromCharCode(10);
|
||||
document.getElementById('agentapi').textContent =
|
||||
'Authorization: Bearer ' + myKey + NL +
|
||||
'GET /api/catalog # tools (free tier)' + NL +
|
||||
'POST /api/tools/{id}/call # metered in sats' + NL +
|
||||
'POST /api/mcp/list # MCP tool list' + NL +
|
||||
'plan=' + plan + (plan==='pro' ? ' (10,000 calls/mo)' : ' (100 calls/mo free)');
|
||||
function bindSubscribe(){
|
||||
var b=document.getElementById('subscribe');
|
||||
b.disabled = (plan==='pro');
|
||||
if(plan==='pro'){ b.textContent='You are on Pro'; b.style.opacity='.6'; }
|
||||
b.onclick=function(){
|
||||
document.getElementById('paystatus').textContent='Waiting for BTCPay...';
|
||||
fetch('/api/subscribe',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'})
|
||||
.then(function(r){return r.json();})
|
||||
.then(function(d){
|
||||
if(d.checkout_link){
|
||||
document.getElementById('check').style.display='inline-flex';
|
||||
document.getElementById('relink').style.display='inline-flex';
|
||||
document.getElementById('relink').href=d.checkout_link;
|
||||
document.getElementById('paystatus').textContent='Redirecting you to the BTC invoice...';
|
||||
setTimeout(function(){window.location=d.checkout_link;},700);
|
||||
} else { document.getElementById('paystatus').textContent='Could not create invoice: '+(d.error||'?'); }
|
||||
})
|
||||
.catch(function(e){document.getElementById('paystatus').textContent='Error: '+e;});
|
||||
};
|
||||
}
|
||||
function bindCheck(){
|
||||
var b=document.getElementById('check');
|
||||
b.onclick=function(){
|
||||
document.getElementById('paystatus').textContent='Checking with BTCPay...';
|
||||
fetch('/api/check_payment')
|
||||
.then(function(r){return r.json();})
|
||||
.then(function(d){
|
||||
if(d.paid){ location.reload(); }
|
||||
else{ document.getElementById('paystatus').textContent='Payment not detected yet. Keep paying, we will poll the webhook too.'; }
|
||||
})
|
||||
.catch(function(e){document.getElementById('paystatus').textContent='Error: '+e;});
|
||||
};
|
||||
}
|
||||
bindSubscribe(); bindCheck();
|
||||
if(pendingInvoice){
|
||||
document.getElementById('relink').style.display='inline-flex';
|
||||
document.getElementById('relink').href=pendingInvoice.checkout_link;
|
||||
document.getElementById('check').style.display='inline-flex';
|
||||
}
|
||||
</script>
|
||||
""" % {
|
||||
"badge": badge, "key": u["api_key"], "calls": u["call_count"],
|
||||
"limit": (10000 if plan == "pro" else 100),
|
||||
"expiry": expiry, "price": int(PRICE_USD),
|
||||
"paidmsg": ('<div class="badge pro" style="margin:14px 0">✓ Payment received — Pro active</div>' if just_paid else ''),
|
||||
"btcpay": BTCPAY_URL, "plan": plan,
|
||||
"inv": json.dumps({"checkout_link": invoice["checkout_link"]} if invoice else None),
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# About
|
||||
# ---------------------------------------------------------------------------
|
||||
def about_body():
|
||||
return r"""
|
||||
<section class="hero" style="padding-bottom:30px">
|
||||
<span class="eyebrow"><span class="dot"></span> About</span>
|
||||
<h1 style="margin-top:20px">Built for the world<br>of <span class="grad">autonomous</span> agents.</h1>
|
||||
<p class="lead">Hyperion is a B2B infrastructure play: a hosted, Bitcoin-billed marketplace for MCP tools
|
||||
that AI agents consume machine-to-machine. No accounts that need a human, no cards, no KYC —
|
||||
just a key, a meter, and sats.</p>
|
||||
</section>
|
||||
<div class="divider"></div>
|
||||
<div class="grid c3">
|
||||
<div class="card"><div class="icon">⚁</div><h3>MCP-native</h3><p>The catalog is itself an MCP server, so any agent can speak it.</p></div>
|
||||
<div class="card"><div class="icon">◎</div><h3>Bitcoin only</h3><p>Settled on-chain through BTCPay. Payable in sats, private, no intermediary.</p></div>
|
||||
<div class="card"><div class="icon">🔒</div><h3>No KYC</h3><p>Username and password. Your key is what you are. That is the whole identity story.</p></div>
|
||||
</div>
|
||||
<div class="divider"></div>
|
||||
<p>Open source. Grab the code, read every line, ship your own node if you like:</p>
|
||||
<p style="margin-top:14px"><a class="btn primary" href="%(github)s" target="_blank" rel="noopener">View on GitHub →</a></p>
|
||||
""" % {"github": GITHUB_URL}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Routes — public pages
|
||||
# ---------------------------------------------------------------------------
|
||||
@app.route("/")
|
||||
def index():
|
||||
user = None
|
||||
if "user_id" in session:
|
||||
user = get_db().execute("SELECT * FROM users WHERE id=?", (session["user_id"],)).fetchone()
|
||||
return render("The App Store for AI agents", landing_body(), user)
|
||||
|
||||
@app.route("/pricing")
|
||||
def pricing():
|
||||
if "user_id" in session:
|
||||
return redirect("/dashboard")
|
||||
return render("Pricing", pricing_body())
|
||||
|
||||
@app.route("/about")
|
||||
def about():
|
||||
if "user_id" in session:
|
||||
return redirect("/dashboard")
|
||||
return render("About", about_body())
|
||||
|
||||
@app.route("/health")
|
||||
def health():
|
||||
db = get_db()
|
||||
users = db.execute("SELECT COUNT(*) AS c FROM users").fetchone()["c"]
|
||||
invoices = db.execute("SELECT COUNT(*) AS c FROM invoices").fetchone()["c"]
|
||||
return jsonify(status="ok", service="hyperion", time=now_iso(),
|
||||
users=users, invoices=invoices, btcpay=BTCPAY_URL, price_usd=PRICE_USD)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auth routes
|
||||
# ---------------------------------------------------------------------------
|
||||
@app.route("/register", methods=["GET", "POST"])
|
||||
def register_page():
|
||||
err = ""
|
||||
if request.method == "POST":
|
||||
username = (request.form.get("username") or "").strip()
|
||||
password = request.form.get("password") or ""
|
||||
email = (request.form.get("email") or "").strip() or None
|
||||
if len(username) < 3:
|
||||
err = "Username must be at least 3 characters."
|
||||
elif len(password) < 6:
|
||||
err = "Password must be at least 6 characters."
|
||||
else:
|
||||
db = get_db()
|
||||
existing = db.execute("SELECT id FROM users WHERE username=?", (username,)).fetchone()
|
||||
if existing:
|
||||
err = "That username is taken."
|
||||
else:
|
||||
api_key = "hyper_" + secrets.token_urlsafe(28)
|
||||
db.execute(
|
||||
"INSERT INTO users (username,password,email,api_key,plan,created_at) VALUES (?,?,?,?,?,?)",
|
||||
(username, generate_password_hash(password), email, api_key, "free", now_iso()))
|
||||
db.commit()
|
||||
uid = db.execute("SELECT id FROM users WHERE username=?", (username,)).fetchone()["id"]
|
||||
session["user_id"] = uid
|
||||
session["username"] = username
|
||||
return redirect("/dashboard")
|
||||
return render("Sign up", register_body(err))
|
||||
|
||||
@app.route("/login", methods=["GET", "POST"])
|
||||
def login_page():
|
||||
err = ""
|
||||
if request.method == "POST":
|
||||
username = (request.form.get("username") or "").strip()
|
||||
password = request.form.get("password") or ""
|
||||
db = get_db()
|
||||
row = db.execute("SELECT * FROM users WHERE username=?", (username,)).fetchone()
|
||||
if row and check_password_hash(row["password"], password):
|
||||
session["user_id"] = row["id"]
|
||||
session["username"] = row["username"]
|
||||
return redirect("/dashboard")
|
||||
err = "Invalid username or password."
|
||||
return render("Log in", login_body(err))
|
||||
|
||||
@app.route("/logout")
|
||||
def logout():
|
||||
session.clear()
|
||||
return redirect("/")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dashboard + payment API
|
||||
# ---------------------------------------------------------------------------
|
||||
@app.route("/dashboard")
|
||||
@login_required
|
||||
def dashboard():
|
||||
db = get_db()
|
||||
u = db.execute("SELECT * FROM users WHERE id=?", (session["user_id"],)).fetchone()
|
||||
if not u:
|
||||
session.clear()
|
||||
return redirect("/login")
|
||||
inv = db.execute("SELECT * FROM invoices WHERE user_id=? AND status=? ORDER BY id DESC LIMIT 1",
|
||||
(session["user_id"], "pending")).fetchone()
|
||||
inv_obj = dict(inv) if inv else None
|
||||
just_paid = bool(session.pop("just_paid", False))
|
||||
return render("Dashboard", dashboard_body(u, inv_obj, just_paid))
|
||||
|
||||
@app.route("/api/subscribe", methods=["POST"])
|
||||
@login_required
|
||||
def api_subscribe():
|
||||
uid = session["user_id"]
|
||||
db = get_db()
|
||||
u = db.execute("SELECT * FROM users WHERE id=?", (uid,)).fetchone()
|
||||
if not u:
|
||||
return jsonify(error="user not found"), 404
|
||||
if u["plan"] == "pro":
|
||||
return jsonify(error="already on pro")
|
||||
try:
|
||||
inv = create_invoice("Hyperion Pro $%d/mo" % int(PRICE_USD),
|
||||
{"orderId": "HYPERION-%d" % uid, "orderUrl": "", "buyerEmail": (u["email"] or "%s@hyperion" % u["username"])})
|
||||
except Exception as e:
|
||||
return jsonify(error=str(e)[:300]), 502
|
||||
inv_id = inv.get("id")
|
||||
link = inv.get("checkoutLink") or (f"{BTCPAY_URL}/i/{inv_id}")
|
||||
db.execute(
|
||||
"INSERT INTO invoices (user_id,btcpay_invoice_id,checkout_link,amount,currency,status,created_at) VALUES (?,?,?,?,?,?,?)",
|
||||
(uid, inv_id, link, PRICE_USD, "USD", "pending", now_iso()))
|
||||
db.commit()
|
||||
return jsonify(btcpay_invoice_id=inv_id, checkout_link=link)
|
||||
|
||||
@app.route("/api/check_payment")
|
||||
@login_required
|
||||
def api_check_payment():
|
||||
uid = session["user_id"]
|
||||
db = get_db()
|
||||
inv = db.execute("SELECT * FROM invoices WHERE user_id=? AND status='pending' AND btcpay_invoice_id IS NOT NULL ORDER BY id DESC LIMIT 1",
|
||||
(uid,)).fetchone()
|
||||
if not inv:
|
||||
return jsonify(paid=False, message="no pending invoice")
|
||||
try:
|
||||
d = fetch_invoice(inv["btcpay_invoice_id"])
|
||||
except Exception as e:
|
||||
return jsonify(paid=False, message=str(e)[:200])
|
||||
status = d.get("status")
|
||||
if status in PAID_STATES:
|
||||
activate_pro(uid)
|
||||
db.execute("UPDATE invoices SET status='paid' WHERE btcpay_invoice_id=?", (inv["btcpay_invoice_id"],))
|
||||
db.commit()
|
||||
session["just_paid"] = True
|
||||
return jsonify(paid=True, status=status)
|
||||
return jsonify(paid=False, status=status)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# BTCPay webhook (phase 2 wired to /webhook/btcpay)
|
||||
# ---------------------------------------------------------------------------
|
||||
@app.route("/webhook/btcpay", methods=["POST", "GET"])
|
||||
def webhook_btcpay():
|
||||
data = request.get_json(silent=True)
|
||||
if not isinstance(data, dict):
|
||||
try:
|
||||
data = json.loads(request.get_data(as_text=True))
|
||||
except Exception:
|
||||
data = {}
|
||||
inv_id = data.get("invoiceId") or data.get("id") or (request.args.get("invoiceId") if request.args else None)
|
||||
notification = data.get("notification") or data.get("status") or ""
|
||||
db = get_db()
|
||||
if inv_id:
|
||||
row = db.execute("SELECT * FROM invoices WHERE btcpay_invoice_id=?", (inv_id,)).fetchone()
|
||||
if row and row["status"] == "pending":
|
||||
paid = notification in PAID_STATES
|
||||
expired = notification == "Expired"
|
||||
if paid:
|
||||
activate_pro(row["user_id"])
|
||||
db.execute("UPDATE invoices SET status='paid' WHERE id=?", (row["id"],))
|
||||
db.commit()
|
||||
elif expired:
|
||||
db.execute("UPDATE invoices SET status='expired' WHERE id=?", (row["id"],))
|
||||
db.commit()
|
||||
return ("", 200)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Agent-facing API
|
||||
# ---------------------------------------------------------------------------
|
||||
CATALOG = [
|
||||
{"id": "search", "name": "Search", "desc": "Semantic search over any corpus", "sats": 500},
|
||||
{"id": "summarize", "name": "Summarize", "desc": "Condense docs to an abstract", "sats": 700},
|
||||
{"id": "translate", "name": "Translate", "desc": "40 languages, fast", "sats": 300},
|
||||
{"id": "code_review", "name": "Code Review", "desc": "Security + style lint", "sats": 1200},
|
||||
{"id": "extract", "name": "Extract", "desc": "Structured fields from text", "sats": 400},
|
||||
]
|
||||
|
||||
@app.route("/api/catalog")
|
||||
def api_catalog():
|
||||
return jsonify(tools=CATALOG, currency="sats", free_limit=100, pro_limit=10000)
|
||||
|
||||
@app.route("/api/mcp/list")
|
||||
def api_mcp_list():
|
||||
tools = [{"name": t["id"], "description": t["desc"], "inputSchema": {"type": "object"}} for t in CATALOG]
|
||||
return json.dumps({"jsonrpc": "2.0", "result": {"tools": tools}}), 200, {"Content-Type": "application/json"}
|
||||
|
||||
def _check_api_key():
|
||||
key = (request.headers.get("Authorization") or "").replace("Bearer ", "").strip()
|
||||
if not key.startswith("hyper_"):
|
||||
return None
|
||||
db = get_db()
|
||||
u = db.execute("SELECT * FROM users WHERE api_key=?", (key,)).fetchone()
|
||||
return u
|
||||
|
||||
@app.route("/api/tools/<tool_id>/call", methods=["POST"])
|
||||
def api_call(tool_id):
|
||||
u = _check_api_key()
|
||||
if not u:
|
||||
return jsonify(error="invalid api key"), 401
|
||||
db = get_db()
|
||||
limit = 10000 if u["plan"] == "pro" else 100
|
||||
if u["call_count"] >= limit:
|
||||
return jsonify(error="monthly call limit reached; upgrade to Pro"), 429
|
||||
tool = next((t for t in CATALOG if t["id"] == tool_id), None)
|
||||
if not tool:
|
||||
return jsonify(error="unknown tool"), 404
|
||||
db.execute("UPDATE users SET call_count=call_count+1 WHERE id=?", (u["id"],))
|
||||
db.commit()
|
||||
body = request.get_json(silent=True) or {}
|
||||
result = {
|
||||
"tool": tool_id, "ok": True,
|
||||
"sats_charged": tool["sats"],
|
||||
"result": "Synthesized output for: %s" % str(body.get("input", ""))[:120],
|
||||
"plan": u["plan"],
|
||||
}
|
||||
return jsonify(result)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
init_db()
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(host="0.0.0.0", port=5000, debug=False, threaded=True)
|
||||
Reference in New Issue
Block a user