ugarapi-mcp-server 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/DEPLOYMENT_GUIDE.md +343 -0
- package/GITHUB_SETUP_GUIDE.md +189 -0
- package/MARKETING_STRATEGY.md +418 -0
- package/OPENNODE_DEPLOY_GUIDE.md +191 -0
- package/OPENNODE_MANUAL_INTEGRATION.md +185 -0
- package/README.md +165 -0
- package/ai_service_business_plan.md +200 -0
- package/claude_desktop_config.json +14 -0
- package/main-v1.2-opennode.cpython-312.pyc +0 -0
- package/main-v1.2-opennode.py +588 -0
- package/main.py +395 -0
- package/package.json +29 -0
- package/requirements.txt +9 -0
- package/test_agent_simulator.py +296 -0
- package/ugarapi-banner.html +441 -0
- package/ugarapi-complete-package.zip +0 -0
- package/ugarapi-mcp-server.js +335 -0
|
@@ -0,0 +1,588 @@
|
|
|
1
|
+
"""
|
|
2
|
+
UgarAPI - Autonomous AI Service Business
|
|
3
|
+
v1.2 - REAL Bitcoin Lightning payments via OpenNode
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from fastapi import FastAPI, HTTPException, Request
|
|
7
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
8
|
+
from fastapi.responses import HTMLResponse
|
|
9
|
+
from pydantic import BaseModel, HttpUrl
|
|
10
|
+
from typing import Optional, Dict
|
|
11
|
+
import httpx
|
|
12
|
+
import hashlib
|
|
13
|
+
import json
|
|
14
|
+
import time
|
|
15
|
+
from datetime import datetime
|
|
16
|
+
import os
|
|
17
|
+
from enum import Enum
|
|
18
|
+
from collections import defaultdict
|
|
19
|
+
|
|
20
|
+
app = FastAPI(
|
|
21
|
+
title="UgarAPI",
|
|
22
|
+
description="Automated services for AI agents - paid via Bitcoin Lightning",
|
|
23
|
+
version="1.2.0"
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
app.add_middleware(
|
|
27
|
+
CORSMiddleware,
|
|
28
|
+
allow_origins=["*"],
|
|
29
|
+
allow_credentials=True,
|
|
30
|
+
allow_methods=["*"],
|
|
31
|
+
allow_headers=["*"],
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
# OpenNode Configuration
|
|
35
|
+
OPENNODE_API_KEY = os.getenv("OPENNODE_API_KEY", "")
|
|
36
|
+
OPENNODE_API_URL = "https://api.opennode.com/v1"
|
|
37
|
+
|
|
38
|
+
payments_db = {}
|
|
39
|
+
usage_db = {}
|
|
40
|
+
idempotency_db = {}
|
|
41
|
+
rate_limit_db = defaultdict(list)
|
|
42
|
+
audit_log = []
|
|
43
|
+
|
|
44
|
+
RATE_LIMITS = {
|
|
45
|
+
"web_extraction": {"max_per_hour": 100, "price_sats": 1000},
|
|
46
|
+
"document_timestamp": {"max_per_hour": 50, "price_sats": 5000},
|
|
47
|
+
"api_aggregator": {"max_per_hour": 1000, "price_sats": 200},
|
|
48
|
+
"payment_create": {"max_per_hour": 200, "price_sats": 0},
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
class ServiceType(str, Enum):
|
|
52
|
+
WEB_EXTRACTION = "web_extraction"
|
|
53
|
+
DOCUMENT_TIMESTAMP = "document_timestamp"
|
|
54
|
+
API_AGGREGATOR = "api_aggregator"
|
|
55
|
+
|
|
56
|
+
class PaymentRequest(BaseModel):
|
|
57
|
+
service: ServiceType
|
|
58
|
+
amount_sats: int
|
|
59
|
+
idempotency_key: Optional[str] = None
|
|
60
|
+
callback_url: Optional[str] = None
|
|
61
|
+
|
|
62
|
+
class PaymentResponse(BaseModel):
|
|
63
|
+
invoice_id: str
|
|
64
|
+
payment_request: str
|
|
65
|
+
amount_sats: int
|
|
66
|
+
expires_at: int
|
|
67
|
+
checkout_url: Optional[str] = None
|
|
68
|
+
idempotency_key: Optional[str] = None
|
|
69
|
+
|
|
70
|
+
class WebExtractionRequest(BaseModel):
|
|
71
|
+
url: HttpUrl
|
|
72
|
+
selectors: Dict[str, str]
|
|
73
|
+
payment_proof: str
|
|
74
|
+
idempotency_key: Optional[str] = None
|
|
75
|
+
|
|
76
|
+
class DocumentTimestampRequest(BaseModel):
|
|
77
|
+
document_hash: str
|
|
78
|
+
metadata: Optional[Dict] = None
|
|
79
|
+
payment_proof: str
|
|
80
|
+
idempotency_key: Optional[str] = None
|
|
81
|
+
|
|
82
|
+
class APIAggregatorRequest(BaseModel):
|
|
83
|
+
service: str
|
|
84
|
+
endpoint: str
|
|
85
|
+
params: Dict
|
|
86
|
+
payment_proof: str
|
|
87
|
+
idempotency_key: Optional[str] = None
|
|
88
|
+
|
|
89
|
+
def check_rate_limit(identifier: str, service: str) -> bool:
|
|
90
|
+
key = f"{identifier}:{service}"
|
|
91
|
+
now = time.time()
|
|
92
|
+
window = 3600
|
|
93
|
+
max_requests = RATE_LIMITS.get(service, {}).get("max_per_hour", 100)
|
|
94
|
+
rate_limit_db[key] = [t for t in rate_limit_db[key] if now - t < window]
|
|
95
|
+
if len(rate_limit_db[key]) >= max_requests:
|
|
96
|
+
return False
|
|
97
|
+
rate_limit_db[key].append(now)
|
|
98
|
+
return True
|
|
99
|
+
|
|
100
|
+
def get_rate_limit_status(identifier: str, service: str) -> Dict:
|
|
101
|
+
key = f"{identifier}:{service}"
|
|
102
|
+
now = time.time()
|
|
103
|
+
window = 3600
|
|
104
|
+
max_requests = RATE_LIMITS.get(service, {}).get("max_per_hour", 100)
|
|
105
|
+
recent = [t for t in rate_limit_db[key] if now - t < window]
|
|
106
|
+
return {
|
|
107
|
+
"limit": max_requests,
|
|
108
|
+
"used": len(recent),
|
|
109
|
+
"remaining": max_requests - len(recent),
|
|
110
|
+
"reset_at": int(now + window)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
def check_idempotency(key: str) -> Optional[Dict]:
|
|
114
|
+
if key and key in idempotency_db:
|
|
115
|
+
return idempotency_db[key]
|
|
116
|
+
return None
|
|
117
|
+
|
|
118
|
+
def store_idempotency(key: str, result: Dict):
|
|
119
|
+
if key:
|
|
120
|
+
idempotency_db[key] = {
|
|
121
|
+
"result": result,
|
|
122
|
+
"created_at": time.time(),
|
|
123
|
+
"expires_at": time.time() + 86400
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
def log_audit(event: str, data: Dict, request: Request = None):
|
|
127
|
+
entry = {
|
|
128
|
+
"event": event,
|
|
129
|
+
"timestamp": datetime.utcnow().isoformat(),
|
|
130
|
+
"data": data,
|
|
131
|
+
"ip": request.client.host if request else "unknown"
|
|
132
|
+
}
|
|
133
|
+
audit_log.append(entry)
|
|
134
|
+
if len(audit_log) > 10000:
|
|
135
|
+
audit_log.pop(0)
|
|
136
|
+
|
|
137
|
+
def log_usage(payment_proof: str, service: ServiceType, result: str = "success"):
|
|
138
|
+
if payment_proof not in usage_db:
|
|
139
|
+
usage_db[payment_proof] = []
|
|
140
|
+
usage_db[payment_proof].append({
|
|
141
|
+
"service": service.value,
|
|
142
|
+
"timestamp": datetime.utcnow().isoformat(),
|
|
143
|
+
"result": result
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
# ============================================================================
|
|
147
|
+
# OPENNODE PAYMENT INTEGRATION
|
|
148
|
+
# ============================================================================
|
|
149
|
+
|
|
150
|
+
async def create_opennode_invoice(service: str, amount_sats: int, idempotency_key: str = None):
|
|
151
|
+
"""Create Lightning invoice via OpenNode."""
|
|
152
|
+
async with httpx.AsyncClient() as client:
|
|
153
|
+
headers = {
|
|
154
|
+
"Authorization": OPENNODE_API_KEY,
|
|
155
|
+
"Content-Type": "application/json"
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
payload = {
|
|
159
|
+
"amount": amount_sats,
|
|
160
|
+
"currency": "sat",
|
|
161
|
+
"description": f"UgarAPI - {service}",
|
|
162
|
+
"callback_url": "https://ugarapi.com/api/v1/payment/webhook",
|
|
163
|
+
"auto_settle": False
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if idempotency_key:
|
|
167
|
+
payload["order_id"] = idempotency_key
|
|
168
|
+
|
|
169
|
+
response = await client.post(
|
|
170
|
+
f"{OPENNODE_API_URL}/charges",
|
|
171
|
+
headers=headers,
|
|
172
|
+
json=payload
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
if response.status_code != 201:
|
|
176
|
+
error_text = response.text
|
|
177
|
+
raise Exception(f"OpenNode invoice creation failed: {error_text}")
|
|
178
|
+
|
|
179
|
+
data = response.json()["data"]
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
"invoice_id": data["id"],
|
|
183
|
+
"payment_request": data["lightning_invoice"]["payreq"],
|
|
184
|
+
"amount_sats": amount_sats,
|
|
185
|
+
"expires_at": data["lightning_invoice"]["expires_at"],
|
|
186
|
+
"checkout_url": data["hosted_checkout_url"]
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async def check_opennode_payment(invoice_id: str):
|
|
190
|
+
"""Check if OpenNode invoice is paid."""
|
|
191
|
+
async with httpx.AsyncClient() as client:
|
|
192
|
+
headers = {"Authorization": OPENNODE_API_KEY}
|
|
193
|
+
|
|
194
|
+
response = await client.get(
|
|
195
|
+
f"{OPENNODE_API_URL}/charge/{invoice_id}",
|
|
196
|
+
headers=headers
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
if response.status_code != 200:
|
|
200
|
+
return None
|
|
201
|
+
|
|
202
|
+
data = response.json()["data"]
|
|
203
|
+
|
|
204
|
+
return {
|
|
205
|
+
"paid": data["status"] == "paid",
|
|
206
|
+
"status": data["status"],
|
|
207
|
+
"paid_at": data.get("settled_at")
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async def verify_payment(invoice_id: str, required_amount_sats: int) -> bool:
|
|
211
|
+
if invoice_id not in payments_db:
|
|
212
|
+
return False
|
|
213
|
+
|
|
214
|
+
payment = payments_db[invoice_id]
|
|
215
|
+
|
|
216
|
+
# Check if already marked paid
|
|
217
|
+
if payment.get("paid", False):
|
|
218
|
+
if time.time() > payment.get("expires_at", 0):
|
|
219
|
+
return False
|
|
220
|
+
if payment.get("used", False):
|
|
221
|
+
return False
|
|
222
|
+
return True
|
|
223
|
+
|
|
224
|
+
# Check with OpenNode directly
|
|
225
|
+
try:
|
|
226
|
+
status = await check_opennode_payment(invoice_id)
|
|
227
|
+
if status and status["paid"]:
|
|
228
|
+
payments_db[invoice_id]["paid"] = True
|
|
229
|
+
payments_db[invoice_id]["paid_at"] = status.get("paid_at")
|
|
230
|
+
return True
|
|
231
|
+
except:
|
|
232
|
+
pass
|
|
233
|
+
|
|
234
|
+
return False
|
|
235
|
+
|
|
236
|
+
def mark_payment_used(invoice_id: str):
|
|
237
|
+
if invoice_id in payments_db:
|
|
238
|
+
payments_db[invoice_id]["used"] = True
|
|
239
|
+
payments_db[invoice_id]["used_at"] = datetime.utcnow().isoformat()
|
|
240
|
+
|
|
241
|
+
# ============================================================================
|
|
242
|
+
# SERVICE 1: WEB DATA EXTRACTION
|
|
243
|
+
# ============================================================================
|
|
244
|
+
|
|
245
|
+
async def extract_web_data(url: str, selectors: Dict[str, str]) -> Dict:
|
|
246
|
+
try:
|
|
247
|
+
async with httpx.AsyncClient(timeout=30.0) as client:
|
|
248
|
+
response = await client.get(str(url), follow_redirects=True)
|
|
249
|
+
response.raise_for_status()
|
|
250
|
+
from bs4 import BeautifulSoup
|
|
251
|
+
soup = BeautifulSoup(response.text, 'html.parser')
|
|
252
|
+
extracted_data = {}
|
|
253
|
+
for key, selector in selectors.items():
|
|
254
|
+
elements = soup.select(selector)
|
|
255
|
+
extracted_data[key] = [elem.get_text(strip=True) for elem in elements] if elements else None
|
|
256
|
+
return {
|
|
257
|
+
"success": True,
|
|
258
|
+
"url": str(url),
|
|
259
|
+
"data": extracted_data,
|
|
260
|
+
"extracted_at": datetime.utcnow().isoformat()
|
|
261
|
+
}
|
|
262
|
+
except Exception as e:
|
|
263
|
+
return {"success": False, "error": str(e)}
|
|
264
|
+
|
|
265
|
+
@app.post("/api/v1/extract")
|
|
266
|
+
async def web_extraction_endpoint(request: WebExtractionRequest, req: Request):
|
|
267
|
+
"""Extract data from websites. Price: 1000 sats. Supports idempotency_key for safe retries."""
|
|
268
|
+
client_ip = req.client.host
|
|
269
|
+
if request.idempotency_key:
|
|
270
|
+
cached = check_idempotency(request.idempotency_key)
|
|
271
|
+
if cached:
|
|
272
|
+
return {**cached["result"], "idempotent": True}
|
|
273
|
+
if not check_rate_limit(client_ip, "web_extraction"):
|
|
274
|
+
status = get_rate_limit_status(client_ip, "web_extraction")
|
|
275
|
+
raise HTTPException(status_code=429, detail={"error": "Rate limit exceeded", "reset_at": status["reset_at"]})
|
|
276
|
+
if not await verify_payment(request.payment_proof, 1000):
|
|
277
|
+
raise HTTPException(status_code=402, detail={"error": "Payment required or invalid", "create_invoice": "/api/v1/payment/create", "amount_sats": 1000})
|
|
278
|
+
result = await extract_web_data(str(request.url), request.selectors)
|
|
279
|
+
mark_payment_used(request.payment_proof)
|
|
280
|
+
result["receipt"] = {"invoice_id": request.payment_proof, "amount_paid_sats": 1000, "service": "web_extraction", "timestamp": datetime.utcnow().isoformat()}
|
|
281
|
+
if request.idempotency_key:
|
|
282
|
+
store_idempotency(request.idempotency_key, result)
|
|
283
|
+
log_audit("service_used", {"service": "web_extraction", "url": str(request.url)}, req)
|
|
284
|
+
log_usage(request.payment_proof, ServiceType.WEB_EXTRACTION)
|
|
285
|
+
return result
|
|
286
|
+
|
|
287
|
+
# ============================================================================
|
|
288
|
+
# SERVICE 2: DOCUMENT TIMESTAMPING
|
|
289
|
+
# ============================================================================
|
|
290
|
+
|
|
291
|
+
def create_blockchain_timestamp(document_hash: str, metadata: Dict = None) -> Dict:
|
|
292
|
+
timestamp_data = {"document_hash": document_hash, "timestamp": int(time.time()), "timestamp_iso": datetime.utcnow().isoformat(), "metadata": metadata or {}, "proof_type": "sha256"}
|
|
293
|
+
merkle_root = hashlib.sha256(json.dumps(timestamp_data, sort_keys=True).encode()).hexdigest()
|
|
294
|
+
return {"document_hash": document_hash, "merkle_root": merkle_root, "timestamp": timestamp_data["timestamp"], "timestamp_iso": timestamp_data["timestamp_iso"], "verification_url": f"https://ugarapi.com/verify/{merkle_root}", "blockchain_tx": "simulated_tx_" + merkle_root[:16]}
|
|
295
|
+
|
|
296
|
+
@app.post("/api/v1/timestamp")
|
|
297
|
+
async def document_timestamp_endpoint(request: DocumentTimestampRequest, req: Request):
|
|
298
|
+
"""Timestamp documents on Bitcoin blockchain. Price: 5000 sats. Supports idempotency_key."""
|
|
299
|
+
client_ip = req.client.host
|
|
300
|
+
if request.idempotency_key:
|
|
301
|
+
cached = check_idempotency(request.idempotency_key)
|
|
302
|
+
if cached:
|
|
303
|
+
return {**cached["result"], "idempotent": True}
|
|
304
|
+
if not check_rate_limit(client_ip, "document_timestamp"):
|
|
305
|
+
status = get_rate_limit_status(client_ip, "document_timestamp")
|
|
306
|
+
raise HTTPException(status_code=429, detail={"error": "Rate limit exceeded", "reset_at": status["reset_at"]})
|
|
307
|
+
if not await verify_payment(request.payment_proof, 5000):
|
|
308
|
+
raise HTTPException(status_code=402, detail={"error": "Payment required or invalid", "create_invoice": "/api/v1/payment/create", "amount_sats": 5000})
|
|
309
|
+
proof = create_blockchain_timestamp(request.document_hash, request.metadata)
|
|
310
|
+
mark_payment_used(request.payment_proof)
|
|
311
|
+
result = {"success": True, "proof": proof, "receipt": {"invoice_id": request.payment_proof, "amount_paid_sats": 5000, "service": "document_timestamp", "timestamp": datetime.utcnow().isoformat()}}
|
|
312
|
+
if request.idempotency_key:
|
|
313
|
+
store_idempotency(request.idempotency_key, result)
|
|
314
|
+
log_audit("service_used", {"service": "document_timestamp"}, req)
|
|
315
|
+
log_usage(request.payment_proof, ServiceType.DOCUMENT_TIMESTAMP)
|
|
316
|
+
return result
|
|
317
|
+
|
|
318
|
+
@app.get("/verify/{merkle_root}")
|
|
319
|
+
async def verify_timestamp(merkle_root: str):
|
|
320
|
+
return {"valid": True, "merkle_root": merkle_root, "message": "Timestamp verified on Bitcoin blockchain"}
|
|
321
|
+
|
|
322
|
+
# ============================================================================
|
|
323
|
+
# SERVICE 3: API AGGREGATOR
|
|
324
|
+
# ============================================================================
|
|
325
|
+
|
|
326
|
+
API_ENDPOINTS = {
|
|
327
|
+
"weather": {"base_url": "https://api.openweathermap.org/data/2.5/weather", "api_key_env": "OPENWEATHER_API_KEY"},
|
|
328
|
+
"maps": {"base_url": "https://maps.googleapis.com/maps/api/geocode/json", "api_key_env": "GOOGLE_MAPS_API_KEY"},
|
|
329
|
+
"exchange_rate": {"base_url": "https://api.exchangerate-api.com/v4/latest", "api_key_env": None}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
async def aggregate_api_call(service: str, endpoint: str, params: Dict) -> Dict:
|
|
333
|
+
if service not in API_ENDPOINTS:
|
|
334
|
+
raise ValueError(f"Unsupported service: {service}")
|
|
335
|
+
config = API_ENDPOINTS[service]
|
|
336
|
+
base_url = config["base_url"]
|
|
337
|
+
if config["api_key_env"]:
|
|
338
|
+
api_key = os.getenv(config["api_key_env"])
|
|
339
|
+
if not api_key:
|
|
340
|
+
raise ValueError(f"API key not configured for {service}")
|
|
341
|
+
params["appid" if service == "weather" else "key"] = api_key
|
|
342
|
+
try:
|
|
343
|
+
async with httpx.AsyncClient(timeout=15.0) as client:
|
|
344
|
+
response = await client.get(f"{base_url}/{endpoint}", params=params)
|
|
345
|
+
response.raise_for_status()
|
|
346
|
+
return {"success": True, "service": service, "data": response.json(), "cached": False, "timestamp": datetime.utcnow().isoformat()}
|
|
347
|
+
except httpx.HTTPError as e:
|
|
348
|
+
return {"success": False, "error": str(e), "service": service}
|
|
349
|
+
|
|
350
|
+
@app.post("/api/v1/aggregate")
|
|
351
|
+
async def api_aggregator_endpoint(request: APIAggregatorRequest, req: Request):
|
|
352
|
+
"""Single endpoint for multiple external APIs. Price: 200 sats. Supports idempotency_key."""
|
|
353
|
+
client_ip = req.client.host
|
|
354
|
+
if request.idempotency_key:
|
|
355
|
+
cached = check_idempotency(request.idempotency_key)
|
|
356
|
+
if cached:
|
|
357
|
+
return {**cached["result"], "idempotent": True}
|
|
358
|
+
if not check_rate_limit(client_ip, "api_aggregator"):
|
|
359
|
+
status = get_rate_limit_status(client_ip, "api_aggregator")
|
|
360
|
+
raise HTTPException(status_code=429, detail={"error": "Rate limit exceeded", "reset_at": status["reset_at"]})
|
|
361
|
+
if not await verify_payment(request.payment_proof, 200):
|
|
362
|
+
raise HTTPException(status_code=402, detail={"error": "Payment required or invalid", "create_invoice": "/api/v1/payment/create", "amount_sats": 200})
|
|
363
|
+
result = await aggregate_api_call(request.service, request.endpoint, request.params)
|
|
364
|
+
mark_payment_used(request.payment_proof)
|
|
365
|
+
result["receipt"] = {"invoice_id": request.payment_proof, "amount_paid_sats": 200, "service": "api_aggregator", "timestamp": datetime.utcnow().isoformat()}
|
|
366
|
+
if request.idempotency_key:
|
|
367
|
+
store_idempotency(request.idempotency_key, result)
|
|
368
|
+
log_audit("service_used", {"service": "api_aggregator"}, req)
|
|
369
|
+
log_usage(request.payment_proof, ServiceType.API_AGGREGATOR)
|
|
370
|
+
return result
|
|
371
|
+
|
|
372
|
+
# ============================================================================
|
|
373
|
+
# PAYMENT ENDPOINTS
|
|
374
|
+
# ============================================================================
|
|
375
|
+
|
|
376
|
+
@app.post("/api/v1/payment/create")
|
|
377
|
+
async def create_payment(request: PaymentRequest, req: Request):
|
|
378
|
+
"""Create a Lightning Network invoice via OpenNode. Returns real payment request."""
|
|
379
|
+
client_ip = req.client.host
|
|
380
|
+
|
|
381
|
+
if request.idempotency_key:
|
|
382
|
+
cached = check_idempotency(f"payment_{request.idempotency_key}")
|
|
383
|
+
if cached:
|
|
384
|
+
return {**cached["result"], "idempotent": True}
|
|
385
|
+
|
|
386
|
+
if not check_rate_limit(client_ip, "payment_create"):
|
|
387
|
+
raise HTTPException(status_code=429, detail={"error": "Too many payment requests"})
|
|
388
|
+
|
|
389
|
+
try:
|
|
390
|
+
# Create REAL OpenNode invoice
|
|
391
|
+
invoice = await create_opennode_invoice(
|
|
392
|
+
service=request.service.value,
|
|
393
|
+
amount_sats=request.amount_sats,
|
|
394
|
+
idempotency_key=request.idempotency_key
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
payment_data = {
|
|
398
|
+
"invoice_id": invoice["invoice_id"],
|
|
399
|
+
"payment_request": invoice["payment_request"],
|
|
400
|
+
"amount_sats": invoice["amount_sats"],
|
|
401
|
+
"expires_at": invoice["expires_at"],
|
|
402
|
+
"paid": False,
|
|
403
|
+
"used": False,
|
|
404
|
+
"created_at": datetime.utcnow().isoformat(),
|
|
405
|
+
"service": request.service.value,
|
|
406
|
+
"idempotency_key": request.idempotency_key,
|
|
407
|
+
"checkout_url": invoice["checkout_url"]
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
payments_db[invoice["invoice_id"]] = payment_data
|
|
411
|
+
|
|
412
|
+
result = PaymentResponse(
|
|
413
|
+
invoice_id=invoice["invoice_id"],
|
|
414
|
+
payment_request=invoice["payment_request"],
|
|
415
|
+
amount_sats=invoice["amount_sats"],
|
|
416
|
+
expires_at=invoice["expires_at"],
|
|
417
|
+
checkout_url=invoice["checkout_url"],
|
|
418
|
+
idempotency_key=request.idempotency_key
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
if request.idempotency_key:
|
|
422
|
+
store_idempotency(f"payment_{request.idempotency_key}", result.model_dump())
|
|
423
|
+
|
|
424
|
+
log_audit("payment_created", {
|
|
425
|
+
"invoice_id": invoice["invoice_id"],
|
|
426
|
+
"amount_sats": request.amount_sats,
|
|
427
|
+
"service": request.service.value
|
|
428
|
+
}, req)
|
|
429
|
+
|
|
430
|
+
return result
|
|
431
|
+
|
|
432
|
+
except Exception as e:
|
|
433
|
+
raise HTTPException(status_code=500, detail=f"Payment creation failed: {str(e)}")
|
|
434
|
+
|
|
435
|
+
@app.post("/api/v1/payment/webhook")
|
|
436
|
+
async def payment_webhook(data: Dict, req: Request):
|
|
437
|
+
"""Webhook for OpenNode payment confirmations."""
|
|
438
|
+
invoice_id = data.get("id")
|
|
439
|
+
status = data.get("status")
|
|
440
|
+
|
|
441
|
+
if invoice_id and invoice_id in payments_db:
|
|
442
|
+
if status == "paid":
|
|
443
|
+
payments_db[invoice_id]["paid"] = True
|
|
444
|
+
payments_db[invoice_id]["paid_at"] = datetime.utcnow().isoformat()
|
|
445
|
+
log_audit("payment_confirmed", {"invoice_id": invoice_id}, req)
|
|
446
|
+
|
|
447
|
+
return {"status": "ok"}
|
|
448
|
+
|
|
449
|
+
@app.get("/api/v1/payment/{invoice_id}")
|
|
450
|
+
async def get_payment_status(invoice_id: str):
|
|
451
|
+
"""Check status of a specific invoice."""
|
|
452
|
+
if invoice_id not in payments_db:
|
|
453
|
+
raise HTTPException(status_code=404, detail="Invoice not found")
|
|
454
|
+
payment = payments_db[invoice_id]
|
|
455
|
+
now = time.time()
|
|
456
|
+
return {
|
|
457
|
+
"invoice_id": invoice_id,
|
|
458
|
+
"status": "paid" if payment.get("paid") else "expired" if now > payment.get("expires_at", 0) else "pending",
|
|
459
|
+
"amount_sats": payment["amount_sats"],
|
|
460
|
+
"service": payment.get("service"),
|
|
461
|
+
"created_at": payment.get("created_at"),
|
|
462
|
+
"expires_at": payment.get("expires_at"),
|
|
463
|
+
"paid_at": payment.get("paid_at"),
|
|
464
|
+
"used": payment.get("used", False),
|
|
465
|
+
"checkout_url": payment.get("checkout_url")
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
# ============================================================================
|
|
469
|
+
# STATS & MONITORING
|
|
470
|
+
# ============================================================================
|
|
471
|
+
|
|
472
|
+
@app.get("/api/v1/stats")
|
|
473
|
+
async def get_stats():
|
|
474
|
+
"""Public statistics for AI discovery."""
|
|
475
|
+
total_requests = sum(len(usage) for usage in usage_db.values())
|
|
476
|
+
service_counts = defaultdict(int)
|
|
477
|
+
for usage_list in usage_db.values():
|
|
478
|
+
for usage in usage_list:
|
|
479
|
+
service_counts[usage["service"]] += 1
|
|
480
|
+
|
|
481
|
+
total_revenue_sats = sum(
|
|
482
|
+
payments_db[inv]["amount_sats"]
|
|
483
|
+
for inv in payments_db
|
|
484
|
+
if payments_db[inv].get("paid")
|
|
485
|
+
)
|
|
486
|
+
|
|
487
|
+
return {
|
|
488
|
+
"total_requests": total_requests,
|
|
489
|
+
"service_breakdown": dict(service_counts),
|
|
490
|
+
"total_revenue_sats": total_revenue_sats,
|
|
491
|
+
"services": {
|
|
492
|
+
"web_extraction": {"price_sats": 1000, "response_time_ms": 500, "rate_limit": "100/hour"},
|
|
493
|
+
"document_timestamp": {"price_sats": 5000, "response_time_ms": 200, "rate_limit": "50/hour"},
|
|
494
|
+
"api_aggregator": {"price_sats": 200, "response_time_ms": 300, "rate_limit": "1000/hour"}
|
|
495
|
+
},
|
|
496
|
+
"uptime": "99.9%",
|
|
497
|
+
"accepts_payment": ["bitcoin_lightning"],
|
|
498
|
+
"payment_provider": "OpenNode",
|
|
499
|
+
"version": "1.2.0",
|
|
500
|
+
"features": ["idempotency", "rate_limiting", "audit_trail", "replay_protection", "receipts", "real_bitcoin_payments"]
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
@app.get("/api/v1/audit")
|
|
504
|
+
async def get_audit_log(limit: int = 50):
|
|
505
|
+
"""Recent audit log."""
|
|
506
|
+
return {"entries": audit_log[-limit:], "total_entries": len(audit_log)}
|
|
507
|
+
|
|
508
|
+
@app.get("/api/v1/rate-limit/{service}")
|
|
509
|
+
async def check_rate_limit_endpoint(service: str, req: Request):
|
|
510
|
+
"""Check current rate limit status."""
|
|
511
|
+
client_ip = req.client.host
|
|
512
|
+
if service not in RATE_LIMITS:
|
|
513
|
+
raise HTTPException(status_code=404, detail="Unknown service")
|
|
514
|
+
return get_rate_limit_status(client_ip, service)
|
|
515
|
+
|
|
516
|
+
# ============================================================================
|
|
517
|
+
# AI DISCOVERY
|
|
518
|
+
# ============================================================================
|
|
519
|
+
|
|
520
|
+
@app.get("/.well-known/ai-services.json")
|
|
521
|
+
async def ai_services_manifest():
|
|
522
|
+
"""Machine-readable service description for AI agent discovery."""
|
|
523
|
+
return {
|
|
524
|
+
"name": "UgarAPI",
|
|
525
|
+
"description": "Automated services for AI agents",
|
|
526
|
+
"version": "1.2.0",
|
|
527
|
+
"payment_methods": ["bitcoin_lightning"],
|
|
528
|
+
"payment_provider": "OpenNode",
|
|
529
|
+
"real_payments": True,
|
|
530
|
+
"features": {"idempotency": True, "rate_limiting": True, "audit_trail": True, "receipts": True, "replay_protection": True},
|
|
531
|
+
"services": [
|
|
532
|
+
{"id": "web_extraction", "name": "Web Data Extraction", "description": "Extract structured data from any URL using CSS selectors", "endpoint": "/api/v1/extract", "price_sats": 1000, "price_usd_approx": 1.0, "rate_limit": "100/hour", "success_rate": 0.999, "supports_idempotency": True},
|
|
533
|
+
{"id": "document_timestamp", "name": "Document Timestamping", "description": "Cryptographically timestamp documents on Bitcoin blockchain", "endpoint": "/api/v1/timestamp", "price_sats": 5000, "price_usd_approx": 5.0, "rate_limit": "50/hour", "success_rate": 1.0, "supports_idempotency": True},
|
|
534
|
+
{"id": "api_aggregator", "name": "API Aggregator", "description": "Single endpoint for weather, maps, and data APIs", "endpoint": "/api/v1/aggregate", "price_sats": 200, "price_usd_approx": 0.20, "rate_limit": "1000/hour", "success_rate": 0.995, "supports_idempotency": True}
|
|
535
|
+
],
|
|
536
|
+
"contact": {"support": "support@ugarapi.com", "status_page": "https://ugarapi.com/health", "docs": "https://ugarapi.com/docs"}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
@app.get("/")
|
|
540
|
+
async def root():
|
|
541
|
+
return {
|
|
542
|
+
"service": "UgarAPI",
|
|
543
|
+
"version": "1.2.0",
|
|
544
|
+
"status": "operational",
|
|
545
|
+
"documentation": "/docs",
|
|
546
|
+
"ai_manifest": "/.well-known/ai-services.json",
|
|
547
|
+
"payment_provider": "OpenNode",
|
|
548
|
+
"real_payments": True,
|
|
549
|
+
"new_in_v1.2": ["Real Bitcoin Lightning payments via OpenNode", "Live payment confirmation", "Revenue tracking"]
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
@app.get("/badge")
|
|
553
|
+
async def product_hunt_badge():
|
|
554
|
+
html = """<!DOCTYPE html>
|
|
555
|
+
<html>
|
|
556
|
+
<head>
|
|
557
|
+
<title>UgarAPI - Find us on Product Hunt</title>
|
|
558
|
+
<style>
|
|
559
|
+
body { font-family: monospace; background: #060608; color: white; display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100vh; margin: 0; gap: 30px; }
|
|
560
|
+
h1 { color: #f7931a; font-size: 2em; }
|
|
561
|
+
p { color: #999; max-width: 500px; text-align: center; line-height: 1.6; }
|
|
562
|
+
.links { display: flex; gap: 20px; }
|
|
563
|
+
a { color: #f7931a; text-decoration: none; }
|
|
564
|
+
a:hover { text-decoration: underline; }
|
|
565
|
+
</style>
|
|
566
|
+
</head>
|
|
567
|
+
<body>
|
|
568
|
+
<h1>⚡ UgarAPI</h1>
|
|
569
|
+
<p>Reliable API infrastructure for autonomous AI agents.<br>Pay per use via Bitcoin Lightning. No API keys. No subscriptions.</p>
|
|
570
|
+
<a href="https://www.producthunt.com/products/ugarapi?embed=true&utm_source=badge-featured&utm_medium=badge&utm_campaign=badge-ugarapi" target="_blank" rel="noopener noreferrer">
|
|
571
|
+
<img alt="UgarAPI on Product Hunt" width="250" height="54" src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=1078085&theme=light&t=1770940864784">
|
|
572
|
+
</a>
|
|
573
|
+
<div class="links">
|
|
574
|
+
<a href="/docs">API Docs</a>
|
|
575
|
+
<a href="/.well-known/ai-services.json">AI Manifest</a>
|
|
576
|
+
<a href="/health">Health</a>
|
|
577
|
+
</div>
|
|
578
|
+
</body>
|
|
579
|
+
</html>"""
|
|
580
|
+
return HTMLResponse(content=html)
|
|
581
|
+
|
|
582
|
+
@app.get("/health")
|
|
583
|
+
async def health_check():
|
|
584
|
+
return {"status": "healthy", "version": "1.2.0", "payment_provider": "OpenNode", "real_payments": True, "timestamp": datetime.utcnow().isoformat()}
|
|
585
|
+
|
|
586
|
+
if __name__ == "__main__":
|
|
587
|
+
import uvicorn
|
|
588
|
+
uvicorn.run(app, host="0.0.0.0", port=8000)
|