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:
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
hyperion.db
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.env
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Hyperion
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
77
README.md
Normal file
77
README.md
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
# Hyperion
|
||||||
|
|
||||||
|
**Hyperion is the App Store for AI agents.** A hosted marketplace where developers publish MCP
|
||||||
|
servers/tools and agents discover, subscribe to, and call them entirely programmatically — no human
|
||||||
|
in the loop. Discovery is MCP-native, access is via programmatically-issued API keys, and billing is
|
||||||
|
Bitcoin pay-per-call / subscription settled through BTCPay. The primary customer is an agent, not a
|
||||||
|
person: an agent finds a tool, pays for a meter in sats, gets a key, and calls it.
|
||||||
|
|
||||||
|
## What it is
|
||||||
|
|
||||||
|
A single-file Flask service. Dark premium UI. Simple auth (username + password, no KYC).
|
||||||
|
Bitcoin-settled subscription via BTCPay, with API keys issued programmatically at signup.
|
||||||
|
|
||||||
|
### Feature overview
|
||||||
|
|
||||||
|
- **MCP-native catalog** — the index itself is an MCP server + JSON API; agents query it, resolve a
|
||||||
|
tool, get metered, and open a stream under a signed API key.
|
||||||
|
- **Programmatic API keys** — keys are handed to an agent the moment it registers (username + password
|
||||||
|
only, no KYC).
|
||||||
|
- **Bitcoin settlement** — Pro subscriptions ($19/mo) and metered calls are paid in BTC through
|
||||||
|
BTCPay. No card, no KYC.
|
||||||
|
- **Free tier** — catalog discovery + 100 metered calls/month + a programmatic API key.
|
||||||
|
- **Pro tier** — unlimited discovery, 10,000 metered calls/month, priority routing, and webhook /
|
||||||
|
metering exports. Publisher side carries a 20% platform fee, settled in BTC.
|
||||||
|
|
||||||
|
## How to run it
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Install dependencies (Python 3)
|
||||||
|
pip3 install flask requests werkzeug
|
||||||
|
|
||||||
|
# 2. Run the service
|
||||||
|
python3 app.py
|
||||||
|
```
|
||||||
|
|
||||||
|
The service binds to `0.0.0.0:5000`. A BTCPay webhook posts to `/webhook/btcpay` to mark invoices paid.
|
||||||
|
|
||||||
|
## Configuration (environment variables)
|
||||||
|
|
||||||
|
All BTCPay values are overridable via env vars; sensible defaults are baked in.
|
||||||
|
|
||||||
|
| Variable | Purpose |
|
||||||
|
| ----------------------- | ------------------------------------------ |
|
||||||
|
| `HYPERION_BTCPAY_URL` | Base URL of the BTCPay instance |
|
||||||
|
| `HYPERION_BTCPAY_STORE` | BTCPay store ID |
|
||||||
|
| `HYPERION_BTCPAY_KEY` | BTCPay API key |
|
||||||
|
| `HYPERION_BTCPAY_WALLET`| xpub (extended public key) of the wallet |
|
||||||
|
| `HYPERION_SECRET` | Flask session secret (random if unset) |
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
HYPERION_BTCPAY_URL=https://btcpay.example \
|
||||||
|
HYPERION_BTCPAY_STORE=<store-id> \
|
||||||
|
HYPERION_BTCPAY_KEY=<api-key> \
|
||||||
|
HYPERION_BTCPAY_WALLET=<xpub> \
|
||||||
|
python3 app.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- `app.py` — the entire application (Flask app, UI, auth, BTCPay integration).
|
||||||
|
- `LICENSE` — MIT License.
|
||||||
|
|
||||||
|
## API surface
|
||||||
|
|
||||||
|
- `/` — landing page (MCP-native catalog pitch).
|
||||||
|
- `/pricing` — Free vs. Pro pricing.
|
||||||
|
- `/register`, `/login`, `/logout` — auth.
|
||||||
|
- `/dashboard` — agent dashboard: API key, metered-call counter, Pro subscription via BTCPay.
|
||||||
|
- `/about`, `/health` — meta endpoints.
|
||||||
|
- `/api/...` — JSON routes authorized by the agent's API key.
|
||||||
|
- `/webhook/btcpay` — BTCPay payment webhook (marks invoices paid, activates Pro).
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT. See `LICENSE`.
|
||||||
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)
|
||||||
3
requirements.txt
Normal file
3
requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
flask>=3.0
|
||||||
|
requests>=2.31
|
||||||
|
werkzeug>=3.0
|
||||||
Reference in New Issue
Block a user