badges: update mock service

This commit is contained in:
Evgeny @ SimpleX Chat
2026-09-20 15:34:00 +00:00
parent ebd0008b96
commit ff3e6cab52
2 changed files with 82 additions and 0 deletions
+21
View File
@@ -183,6 +183,27 @@ which is what the page's own wait loop relies on — no polling on a timer.
Card invoices (`"method":"card"`) get a `clientSecret` in the response
instead of an address; BTC and XMR get `address` and `cryptoAmount`.
### The local build against the real service
The page calls `/api/...` on its own origin, and the service sets no CORS
headers: a page served from `localhost` cannot call `https://badges.simplex.chat`
directly (the browser's preflight `OPTIONS` is answered `405`). So the mock
forwards instead. With `--api <origin>` (or `MOCK_API`), every `/api` request
is relayed to that origin as it came — method, path, query and body — and the
service's status, body and `Retry-After` relayed back; the page runs from the
local build and cannot tell the difference. Nothing is invented, and the
`/control` routes answer `404`: the invoices are real, at the real provider.
```
python3 mock/server.py --port 8099 --api https://badges.simplex.chat
```
Card payments need the service's publishable key in the page, which the
deployed shell carries and the local one does not: set
`STRIPE_PUBLISHABLE_KEY` to the same key (below), or the card lane renders the
development stand-in, whose settle call goes to a `/control` route that is
now refused.
### The Stripe key
The publishable key lives in a `<meta id="stripe-publishable-key">` element
@@ -6,6 +6,12 @@ Standard library only. Threaded, because the wait endpoint holds a connection.
Environment:
MOCK_HOLD_SECONDS how long GET /api/invoice/:id?wait= holds (default 30)
MOCK_API a real service's origin, e.g. https://badges.simplex.chat. Set, every
/api request is forwarded there as it came, with the answer relayed:
the local build then drives real invoices from its own origin, which
is what the service expects — it sets no CORS headers, so a page
cannot call it from another origin. Nothing is invented and
/control/* is refused. --api <origin> on the command line is the same.
STRIPE_PUBLISHABLE_KEY substituted into the
served index.html. Public by design, but still not
committed: unset, the page has NO card form and
@@ -19,10 +25,16 @@ import json, os, re, secrets, sys, threading
from datetime import datetime, timedelta, timezone
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.error import HTTPError, URLError
from urllib.parse import urlparse, parse_qs
from urllib.request import Request, urlopen
ROOT = Path(__file__).resolve().parent.parent
HOLD_SECONDS = float(os.environ.get("MOCK_HOLD_SECONDS", "30"))
API_UPSTREAM = os.environ.get("MOCK_API", "").strip().rstrip("/")
# The service holds a wait for 30 seconds; the forwarded request must outlast it.
UPSTREAM_TIMEOUT_SECONDS = HOLD_SECONDS + 15
UPSTREAM_HEADERS = ("content-type", "retry-after", "cache-control")
STRIPE_PUBLISHABLE_KEY = os.environ.get("STRIPE_PUBLISHABLE_KEY", "").strip()
KEY_META = re.compile(r'(<meta id="stripe-publishable-key"[^>]*content=")[^"]*(")')
@@ -148,9 +160,48 @@ class Handler(BaseHTTPRequestHandler):
except Exception:
return None
def _forward(self):
"""Relays this /api request to API_UPSTREAM: the same method, path, query and JSON body,
and the service's status and body back, so the page cannot tell it is not talking to the
service at its own origin."""
length = int(self.headers.get("content-length") or 0)
body = self.rfile.read(length) if length else None
headers = {"accept": "application/json"}
if body is not None:
headers["content-type"] = self.headers.get("content-type") or "application/json"
req = Request(API_UPSTREAM + self.path, data=body, method=self.command, headers=headers)
try:
with urlopen(req, timeout=UPSTREAM_TIMEOUT_SECONDS) as res:
return self._relay(res.status, res.headers, res.read())
except HTTPError as e:
# A refusal is an answer: the page reads the code from the body and Retry-After from the headers.
return self._relay(e.code, e.headers, e.read())
except (URLError, TimeoutError, OSError) as e:
print(f"mock: {self.command} {self.path} -> {API_UPSTREAM}: {e}", flush=True)
return self._send(502, {"error": "internal"})
def _relay(self, status, headers, body):
try:
self.send_response(status)
for name in UPSTREAM_HEADERS:
value = headers.get(name)
if value:
self.send_header(name, value)
self.send_header("content-length", str(len(body)))
self.end_headers()
self.wfile.write(body)
except (BrokenPipeError, ConnectionResetError):
self.close_connection = True
def do_POST(self):
path = urlparse(self.path).path
if API_UPSTREAM:
if path.startswith("/api/"):
return self._forward()
if path.startswith("/control/"):
return self._send(404, {"error": "not_found"})
if path.startswith("/control/"):
parts = path.strip("/").split("/")
if len(parts) != 3:
@@ -249,6 +300,9 @@ class Handler(BaseHTTPRequestHandler):
# being absent, so blank values are kept.
path, query = parsed.path, parse_qs(parsed.query, keep_blank_values=True)
if API_UPSTREAM and path.startswith("/api/"):
return self._forward()
if path.startswith("/api/invoice/"):
invoice_id = path[len("/api/invoice/"):]
with LOCK:
@@ -303,15 +357,22 @@ class Server(ThreadingHTTPServer):
def main():
global API_UPSTREAM
port = 8099
if "--port" in sys.argv:
port = int(sys.argv[sys.argv.index("--port") + 1])
if "--api" in sys.argv:
API_UPSTREAM = sys.argv[sys.argv.index("--api") + 1].strip().rstrip("/")
if API_UPSTREAM and urlparse(API_UPSTREAM).scheme not in ("http", "https"):
sys.exit("mock: --api / MOCK_API must be an origin such as https://badges.simplex.chat")
if STRIPE_PUBLISHABLE_KEY and not STRIPE_PUBLISHABLE_KEY.startswith("pk_"):
# A secret or restricted key (sk_, rk_) would be written into the page for anyone to read,
# so only a publishable key (pk_) is allowed.
sys.exit("mock: STRIPE_PUBLISHABLE_KEY must be a publishable key (pk_...)")
server = Server(("127.0.0.1", port), Handler)
print(f"mock badge service on http://localhost:{port}", flush=True)
if API_UPSTREAM:
print(f"api: every /api request is forwarded to {API_UPSTREAM} — real invoices, no /control", flush=True)
print("stripe: " + (f"publishable key {STRIPE_PUBLISHABLE_KEY[:11]}… — the real card form"
if STRIPE_PUBLISHABLE_KEY
else "no STRIPE_PUBLISHABLE_KEY — the card path renders the development stand-in"),