Preserve hosted website UX inside Archipelago, add isolated Business and owner-authorized native wallet setup, and include deployment handoff. Based on main with native signer import already accepted.
907 lines
63 KiB
Python
907 lines
63 KiB
Python
#!/usr/bin/env python3
|
||
import json
|
||
import re
|
||
import ipaddress
|
||
import mimetypes
|
||
import os
|
||
import secrets
|
||
import socket
|
||
import sqlite3
|
||
import subprocess
|
||
import threading
|
||
import business_security as security
|
||
import business_rollout as rollout
|
||
import screen_activity
|
||
from datetime import datetime, timezone
|
||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||
from http.cookies import CookieError
|
||
from pathlib import Path
|
||
from urllib.parse import parse_qs, urlparse
|
||
from urllib.parse import urlencode
|
||
from urllib.error import HTTPError
|
||
from urllib.request import Request, urlopen, build_opener, HTTPRedirectHandler
|
||
from urllib.parse import quote
|
||
|
||
ROOT = Path(__file__).resolve().parent
|
||
PUBLIC = ROOT / "public"
|
||
NAPLET_DIST = ROOT / "napplets/order-to-table/dist"
|
||
DATA_DIR = Path(os.environ.get("JWB_DATA_DIR", ROOT / "data"))
|
||
DB_FILE = DATA_DIR / "business.sqlite3"
|
||
HOST = os.environ.get("JWB_HOST", "127.0.0.1")
|
||
PORT = int(os.environ.get("JWB_PORT", "4310"))
|
||
BASE = os.environ.get("JWB_BASE_PATH", "/business-demo").rstrip("/")
|
||
LOCK = threading.RLock()
|
||
PAYMENT_HELPER = ROOT / "payment-helper.cjs"
|
||
PAYMENT_RELAYS = ["wss://relay.primal.net", "wss://nos.lol"]
|
||
PAYMENT_RECIPIENT = "f81611363554b64306467234d7396ec88455707633f54738f6c4683535098cd3"
|
||
LNURL_URL = "https://primal.net/.well-known/lnurlp/fucking"
|
||
DEV_LIGHTNING_ADDRESS = "fucking@primal.net" # dev stand-in matching LNURL_URL, used when a merchant has no real lud16
|
||
LNURL_CALLBACK = "https://primal.net/lnurlp/fucking/callback"
|
||
MODULE_DEFAULTS = {
|
||
"order": (1, "live"), "pay": (1, "live"), "payandgo": (1, "preview"),
|
||
"booking": (1, "live"), "screen": (1, "preview"),
|
||
"gallery": (1, "preview"), "stats": (1, "live"), "events": (0, "coming-soon"),
|
||
}
|
||
|
||
|
||
def now():
|
||
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||
|
||
|
||
def db():
|
||
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||
conn = sqlite3.connect(DB_FILE, timeout=10)
|
||
conn.row_factory = sqlite3.Row
|
||
security.migrate(conn)
|
||
conn.execute("PRAGMA journal_mode=WAL")
|
||
conn.executescript("""
|
||
CREATE TABLE IF NOT EXISTS merchant (
|
||
id TEXT PRIMARY KEY, name TEXT NOT NULL, module_enabled INTEGER NOT NULL,
|
||
table_count INTEGER NOT NULL, updated_at TEXT NOT NULL
|
||
);
|
||
CREATE TABLE IF NOT EXISTS products (
|
||
id TEXT PRIMARY KEY, merchant_id TEXT NOT NULL, name TEXT NOT NULL,
|
||
price_cents INTEGER NOT NULL, enabled INTEGER NOT NULL, sort_order INTEGER NOT NULL
|
||
);
|
||
CREATE TABLE IF NOT EXISTS tables (
|
||
merchant_id TEXT NOT NULL, table_number INTEGER NOT NULL, token TEXT NOT NULL UNIQUE,
|
||
PRIMARY KEY(merchant_id, table_number)
|
||
);
|
||
CREATE TABLE IF NOT EXISTS orders (
|
||
id TEXT PRIMARY KEY, merchant_id TEXT NOT NULL, table_number INTEGER NOT NULL,
|
||
items_json TEXT NOT NULL, note TEXT NOT NULL, status TEXT NOT NULL,
|
||
created_at TEXT NOT NULL, updated_at TEXT NOT NULL
|
||
);
|
||
CREATE TABLE IF NOT EXISTS payments (
|
||
id TEXT PRIMARY KEY, order_id TEXT NOT NULL, merchant_id TEXT NOT NULL,
|
||
table_number INTEGER NOT NULL, amount_cents INTEGER NOT NULL,
|
||
amount_sats INTEGER NOT NULL, bolt11 TEXT NOT NULL, zap_pubkey TEXT NOT NULL,
|
||
status TEXT NOT NULL, receipt_id TEXT, created_at TEXT NOT NULL, settled_at TEXT
|
||
);
|
||
CREATE TABLE IF NOT EXISTS quick_payments (
|
||
id TEXT PRIMARY KEY, merchant_id TEXT NOT NULL, merchant_name TEXT NOT NULL,
|
||
amount_cents INTEGER NOT NULL, amount_sats INTEGER NOT NULL, bolt11 TEXT NOT NULL,
|
||
zap_pubkey TEXT, recipient TEXT, status TEXT NOT NULL, receipt_id TEXT,
|
||
created_at TEXT NOT NULL, settled_at TEXT
|
||
);
|
||
CREATE TABLE IF NOT EXISTS bookings (
|
||
id TEXT PRIMARY KEY, merchant_id TEXT NOT NULL, guest_name TEXT NOT NULL,
|
||
guests INTEGER NOT NULL, scheduled_at TEXT NOT NULL, mode TEXT NOT NULL,
|
||
notes TEXT NOT NULL, status TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL
|
||
);
|
||
CREATE TABLE IF NOT EXISTS merchant_modules (
|
||
merchant_id TEXT NOT NULL, module_id TEXT NOT NULL, enabled INTEGER NOT NULL,
|
||
status TEXT NOT NULL, config_json TEXT NOT NULL, updated_at TEXT NOT NULL,
|
||
PRIMARY KEY(merchant_id,module_id)
|
||
);
|
||
""")
|
||
legacy = rollout.legacy_merchant(conn)
|
||
conn.execute("INSERT OR IGNORE INTO merchant VALUES (?,'LocalPub Madeira — Napplet Demo',1,30,?)", (legacy, now()))
|
||
defaults = [("poncha", "Poncha Fisherman", 350, 1), ("nikita", "Nikita", 450, 2), ("coral", "Coral", 250, 3)]
|
||
for pid, name, cents, order in defaults:
|
||
conn.execute("INSERT OR IGNORE INTO products VALUES (?, ?, ?, ?, 1, ?)", (pid, legacy, name, cents, order))
|
||
for table in range(1, 31):
|
||
conn.execute("INSERT OR IGNORE INTO tables VALUES (?, ?, ?)", (legacy, table, secrets.token_urlsafe(18)))
|
||
for module_id, (enabled, status) in MODULE_DEFAULTS.items():
|
||
conn.execute("INSERT OR IGNORE INTO merchant_modules VALUES (?,?,?,?,?,?)",
|
||
(legacy, module_id, enabled, status, '{}', now()))
|
||
conn.execute("UPDATE merchant_modules SET status='live' WHERE module_id='booking' AND status='preview'")
|
||
conn.commit()
|
||
return conn
|
||
|
||
|
||
def helper(payload):
|
||
result = subprocess.run(["node", str(PAYMENT_HELPER)], input=json.dumps(payload), text=True, capture_output=True, timeout=12, check=True)
|
||
return json.loads(result.stdout)
|
||
|
||
|
||
def fetch_json(url):
|
||
request = Request(url, headers={"User-Agent": "JustWorks-Business/0.1"})
|
||
with urlopen(request, timeout=10) as response:
|
||
return json.loads(response.read())
|
||
|
||
|
||
def screen_tips(npub, lightning_address):
|
||
"""Read-only LNURL metadata + signed NIP-57 announcements; no invoice creation."""
|
||
if lightning_address.count('@') != 1:
|
||
raise ValueError('invalid Lightning address')
|
||
name, host = lightning_address.split('@')
|
||
if not name or not host or any(char in host for char in '/?#:@'):
|
||
raise ValueError('invalid Lightning address')
|
||
endpoint = assert_public_https(f'https://{host}/.well-known/lnurlp/{quote(name, safe="")}')
|
||
|
||
class NoRedirect(HTTPRedirectHandler):
|
||
def redirect_request(self, *args, **kwargs):
|
||
raise ValueError('LNURL redirect is not permitted for screen metadata')
|
||
|
||
with build_opener(NoRedirect).open(Request(endpoint, headers={'User-Agent': 'JustWorks-Screen/1'}), timeout=4) as response:
|
||
raw = response.read(65537)
|
||
if len(raw) > 65536:
|
||
raise ValueError('LNURL metadata too large')
|
||
pay = json.loads(raw)
|
||
provider = pay.get('nostrPubkey')
|
||
if pay.get('tag') != 'payRequest' or pay.get('allowsNostr') is not True or not isinstance(provider, str) or len(provider) != 64 or any(c not in '0123456789abcdef' for c in provider):
|
||
raise ValueError('No verified Nostr receipt source')
|
||
return helper({'action': 'screen-tips', 'npub': npub, 'provider': provider, 'relays': PAYMENT_RELAYS})['tips']
|
||
|
||
|
||
def assert_public_https(url):
|
||
parsed = urlparse(url)
|
||
if parsed.scheme != "https" or not parsed.hostname or parsed.username or parsed.password:
|
||
raise ValueError("Lightning provider returned an unsafe URL")
|
||
addresses = socket.getaddrinfo(parsed.hostname, parsed.port or 443, type=socket.SOCK_STREAM)
|
||
if not addresses or any(not ipaddress.ip_address(item[4][0]).is_global for item in addresses):
|
||
raise ValueError("Lightning provider must use a public host")
|
||
return url
|
||
|
||
|
||
def lightning_invoice(lightning_address, amount_msat, comment=""):
|
||
if "@" not in lightning_address:
|
||
raise ValueError("Merchant Lightning address is invalid")
|
||
name, host = lightning_address.rsplit("@", 1)
|
||
if not name or not host or any(char in host for char in "/?#"):
|
||
raise ValueError("Merchant Lightning address is invalid")
|
||
endpoint = assert_public_https(f"https://{host}/.well-known/lnurlp/{name}")
|
||
pay = fetch_json(endpoint)
|
||
if pay.get("tag") != "payRequest" or not pay.get("callback"):
|
||
raise ValueError("Lightning address does not support payments")
|
||
minimum, maximum = int(pay.get("minSendable", 0)), int(pay.get("maxSendable", 0))
|
||
if amount_msat < minimum or (maximum and amount_msat > maximum):
|
||
raise ValueError(f"Amount must be between {max(1, minimum // 1000)} and {maximum // 1000} sats")
|
||
callback = assert_public_https(str(pay["callback"]))
|
||
# Some LNURL providers reject otherwise valid NIP-57 requests when their
|
||
# signed content contains a literal percent sign. Keep the human meaning
|
||
# while using a provider-safe comment for both the zap and callback.
|
||
safe_comment = str(comment or "").replace("%", " percent").strip()
|
||
params = {"amount": amount_msat}
|
||
proof = None
|
||
recipient = str(pay.get("nostrPubkey") or "").strip()
|
||
if pay.get("allowsNostr") is True and len(recipient) == 64:
|
||
proof = helper({"action": "create", "lnurlUrl": endpoint, "relays": PAYMENT_RELAYS,
|
||
"amountMsat": amount_msat, "recipient": recipient,
|
||
"content": safe_comment or "Just Works payment"})
|
||
params["nostr"] = json.dumps(proof["event"], separators=(",", ":"))
|
||
params["lnurl"] = proof["lnurl"]
|
||
if safe_comment and int(pay.get("commentAllowed", 0)):
|
||
params["comment"] = safe_comment[:int(pay["commentAllowed"])]
|
||
separator = "&" if "?" in callback else "?"
|
||
try:
|
||
invoice = fetch_json(f"{callback}{separator}{urlencode(params)}")
|
||
except HTTPError:
|
||
if not proof:
|
||
raise
|
||
# Invoice creation is the primary action. If a provider advertises
|
||
# NIP-57 but fails its zap path, retry as standard LNURL-pay and mark
|
||
# the result non-verifiable instead of blocking the customer.
|
||
proof = None
|
||
fallback = {key: value for key, value in params.items() if key not in {"nostr", "lnurl"}}
|
||
invoice = fetch_json(f"{callback}{separator}{urlencode(fallback)}")
|
||
if invoice.get("status") == "ERROR" or not invoice.get("pr"):
|
||
raise ValueError(invoice.get("reason", "Lightning invoice unavailable"))
|
||
return {"bolt11": invoice["pr"], "zap_pubkey": proof["pubkey"] if proof else None,
|
||
"recipient": recipient if proof else None, "verifiable": bool(proof)}
|
||
|
||
|
||
def remote_json(url, method="GET", payload=None, timeout=60):
|
||
data = json.dumps(payload).encode() if payload is not None else None
|
||
request = Request(url, data=data, method=method, headers={"User-Agent": "JustWorks-Business/0.1", "Content-Type": "application/json"})
|
||
try:
|
||
with urlopen(request, timeout=timeout) as response:
|
||
return json.loads(response.read())
|
||
except HTTPError as error:
|
||
try: message = json.loads(error.read()).get("error")
|
||
except Exception: message = None
|
||
raise ValueError(message or f"Just Works profile request failed ({error.code})") from error
|
||
|
||
|
||
def resolve_merchant_npub(npub):
|
||
npub = security.canonical_npub(npub)
|
||
# Core resolves every published website; the regional directory is optional
|
||
# presentation metadata, never a merchant allowlist or ownership proof.
|
||
try:
|
||
nsite = fetch_json(f"https://justworks.cash/api/nsites/{npub}").get("nsite", {})
|
||
except HTTPError as error:
|
||
if error.code == 404: return None, None
|
||
raise
|
||
slug = nsite.get("id", "")
|
||
if (nsite.get("npub") != npub or not isinstance(slug, str)
|
||
or not re.fullmatch(r"[a-z0-9]+(?:-[a-z0-9]+)*", slug)
|
||
or nsite.get("nsite", {}).get("deploymentStatus") != "published"):
|
||
return None, None
|
||
place = {"npub": npub, "slug": slug, "name": nsite.get("name") or slug,
|
||
"justworks": f"https://justworks.cash/{slug}"}
|
||
return place, nsite
|
||
|
||
|
||
def merchant_payload(conn, include_tokens=False):
|
||
merchant = dict(conn.execute("SELECT * FROM merchant WHERE id=?", (rollout.legacy_merchant(conn),)).fetchone())
|
||
merchant["module_enabled"] = bool(merchant["module_enabled"])
|
||
merchant["products"] = [dict(row) for row in conn.execute("SELECT id,name,price_cents,enabled,sort_order FROM products WHERE merchant_id=? ORDER BY sort_order", (merchant["id"],))]
|
||
for product in merchant["products"]:
|
||
product["enabled"] = bool(product["enabled"])
|
||
merchant["modules"] = {}
|
||
for row in conn.execute("SELECT module_id,enabled,status,config_json FROM merchant_modules WHERE merchant_id=?", (merchant["id"],)):
|
||
merchant["modules"][row["module_id"]] = {"enabled": bool(row["enabled"]), "status": row["status"], "config": json.loads(row["config_json"] or '{}')}
|
||
if include_tokens:
|
||
merchant["tables"] = [dict(row) for row in conn.execute("SELECT table_number,token FROM tables WHERE merchant_id=? ORDER BY table_number", (merchant["id"],))]
|
||
return merchant
|
||
|
||
|
||
def merchant_modules(conn, merchant_id):
|
||
for module_id, (enabled, status) in MODULE_DEFAULTS.items():
|
||
conn.execute("INSERT OR IGNORE INTO merchant_modules VALUES (?,?,?,?,?,?)",
|
||
(merchant_id, module_id, enabled, status, '{}', now()))
|
||
conn.commit()
|
||
return {row["module_id"]: {"enabled": bool(row["enabled"]), "status": row["status"], "config": json.loads(row["config_json"] or '{}')}
|
||
for row in conn.execute("SELECT module_id,enabled,status,config_json FROM merchant_modules WHERE merchant_id=?", (merchant_id,))}
|
||
|
||
|
||
class Handler(BaseHTTPRequestHandler):
|
||
server_version = "JustWorksBusiness/0.1"
|
||
|
||
def log_message(self, fmt, *args):
|
||
# Query strings may contain table tokens. Do not log request lines or bodies.
|
||
print(json.dumps({"ts": now(), "method": self.command, "status": str(args[1]) if len(args) > 1 else ""}))
|
||
|
||
def send_json(self, status, value):
|
||
mutation = getattr(self, "authorized_mutation", None)
|
||
if mutation and 200 <= status < 300:
|
||
with LOCK, db() as conn:
|
||
security.audit(conn, "mutation", self.route()[0], *mutation)
|
||
self.authorized_mutation = None
|
||
raw = json.dumps(value, separators=(",", ":")).encode()
|
||
self.send_response(status)
|
||
self.send_header("Content-Type", "application/json")
|
||
self.send_header("Content-Length", str(len(raw)))
|
||
self.send_header("Cache-Control", "no-store")
|
||
if getattr(self, "session_cookie", None):
|
||
self.send_header("Set-Cookie", self.session_cookie)
|
||
if getattr(self, "exchange_cookie", None):
|
||
self.send_header("Set-Cookie", self.exchange_cookie)
|
||
self.send_header("Referrer-Policy", "no-referrer")
|
||
self.end_headers()
|
||
self.wfile.write(raw)
|
||
|
||
def redirect(self, location):
|
||
self.send_response(302)
|
||
self.send_header("Location", location)
|
||
self.send_header("Cache-Control", "public,max-age=3600")
|
||
self.end_headers()
|
||
|
||
def body(self):
|
||
size = int(self.headers.get("Content-Length", "0"))
|
||
if size < 0 or size > 64 * 1024:
|
||
raise ValueError("body too large")
|
||
value = json.loads(self.rfile.read(size) or b"{}")
|
||
if not isinstance(value, dict): raise ValueError("JSON object required")
|
||
return value
|
||
|
||
def route(self):
|
||
parsed = urlparse(self.path)
|
||
path = parsed.path
|
||
if BASE and path.startswith(BASE):
|
||
path = path[len(BASE):] or "/"
|
||
return path, parse_qs(parsed.query)
|
||
|
||
def surface(self):
|
||
host = self.headers.get("Host", "").split(":", 1)[0].lower()
|
||
return host.removesuffix(".justworks.cash") if host.endswith(".justworks.cash") else ""
|
||
|
||
def is_local(self):
|
||
if os.environ.get("JWB_ARCHIPELAGO") == "1": return False
|
||
# Anything not served behind the production subdomains is a dev server.
|
||
host = self.headers.get("Host", "").split(":", 1)[0].lower()
|
||
return not host.endswith(".justworks.cash")
|
||
|
||
def static(self, file, cache="no-store"):
|
||
file = file.resolve()
|
||
allowed = [PUBLIC.resolve(), NAPLET_DIST.resolve()]
|
||
if not any(file == root or root in file.parents for root in allowed) or not file.is_file():
|
||
self.send_error(404)
|
||
return
|
||
raw = file.read_bytes()
|
||
if os.environ.get("JWB_ARCHIPELAGO") == "1" and file.suffix == ".html":
|
||
html = raw.decode().replace('"/assets/', '"/business-demo/assets/').replace('"/fonts/', '"/business-demo/fonts/')
|
||
html = html.replace('<head>', '<head><script src="/business-demo/assets/archipelago-paths.js"></script>', 1)
|
||
raw = html.encode()
|
||
mime = mimetypes.guess_type(file.name)[0] or "application/octet-stream"
|
||
self.send_response(200)
|
||
self.send_header("Content-Type", mime)
|
||
self.send_header("Content-Length", str(len(raw)))
|
||
self.send_header("Cache-Control", cache)
|
||
self.send_header("X-Content-Type-Options", "nosniff")
|
||
if file.name == "business-sso.html":
|
||
self.send_header("Referrer-Policy", "no-referrer")
|
||
self.send_header("X-Frame-Options", "DENY")
|
||
self.send_header("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'")
|
||
self.end_headers()
|
||
self.wfile.write(raw)
|
||
|
||
def nsite(self):
|
||
file = PUBLIC / "nsite.html"
|
||
html = file.read_text()
|
||
html = html.replace("</body>", f'<script type="module" src="{BASE}/assets/nsite-live.js"></script></body>')
|
||
raw = html.encode()
|
||
self.send_response(200)
|
||
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||
self.send_header("Content-Length", str(len(raw)))
|
||
self.send_header("Cache-Control", "no-store")
|
||
self.send_header("X-Content-Type-Options", "nosniff")
|
||
self.end_headers()
|
||
self.wfile.write(raw)
|
||
|
||
def app_manifest(self, app, query):
|
||
manifest = json.loads((PUBLIC / f"{app}.webmanifest").read_text())
|
||
npub = (query.get("npub") or [""])[0]
|
||
if npub:
|
||
try: npub = security.canonical_npub(npub)
|
||
except ValueError: return self.send_json(400, {"error": "invalid npub"})
|
||
manifest["start_url"] = f"/?npub={npub}"
|
||
manifest["id"] += f"&npub={npub}"
|
||
raw = json.dumps(manifest, separators=(",", ":")).encode()
|
||
self.send_response(200)
|
||
self.send_header("Content-Type", "application/manifest+json")
|
||
self.send_header("Content-Length", str(len(raw)))
|
||
self.send_header("Cache-Control", "no-store")
|
||
self.send_header("X-Content-Type-Options", "nosniff")
|
||
self.end_headers()
|
||
self.wfile.write(raw)
|
||
|
||
def do_GET(self):
|
||
self.session_cookie = self.exchange_cookie = self.authorized_mutation = None
|
||
path, query = self.route()
|
||
if path == "/api/business-session" or path in security.PRIVILEGED_GET:
|
||
with LOCK, db() as conn:
|
||
if path == "/api/business-session":
|
||
current = security.session(conn, self.headers)
|
||
return self.send_json(200, {"authenticated": bool(current), "session": current, "mode": security.mode(), "sso_enabled": rollout.enabled() and ('https://' + self.headers.get('Host', '').lower()) in rollout.origins(), "legacy_npub": rollout.legacy_merchant(conn) if rollout.legacy_merchant(conn) != 'localpub-demo' else None})
|
||
allowed, _ = security.authorize(conn, "GET", path, {key: value[0] for key, value in query.items()}, self.headers)
|
||
if not allowed: return self.send_json(403, {"error": "Merchant session required for this account"})
|
||
if path == "/healthz": return self.send_json(200, {"ok": True})
|
||
if path == "/api/config":
|
||
with LOCK, db() as conn: return self.send_json(200, merchant_payload(conn))
|
||
if path == "/api/dashboard":
|
||
with LOCK, db() as conn:
|
||
payload = merchant_payload(conn, True)
|
||
payload["orders"] = [dict(row) | {"items": json.loads(row["items_json"])} for row in conn.execute("SELECT * FROM orders WHERE merchant_id=? AND status NOT IN ('delivered','closed') ORDER BY created_at DESC LIMIT 50", (rollout.legacy_merchant(conn),))]
|
||
for item in payload["orders"]: item.pop("items_json", None)
|
||
payload["payments"] = [dict(row) for row in conn.execute("SELECT id,order_id,table_number,amount_cents,amount_sats,status,created_at,settled_at FROM payments WHERE merchant_id=? AND status='paid' ORDER BY settled_at DESC LIMIT 20", (rollout.legacy_merchant(conn),))]
|
||
return self.send_json(200, payload)
|
||
if path == "/api/business":
|
||
npub = (query.get("npub") or [""])[0].strip()
|
||
try:
|
||
place, nsite = resolve_merchant_npub(npub)
|
||
if not place: return self.send_json(404, {"error": "merchant npub not found"})
|
||
with LOCK, db() as conn:
|
||
modules = merchant_modules(conn, npub)
|
||
counts = {
|
||
"orders": conn.execute("SELECT COUNT(*) FROM orders WHERE merchant_id=? AND status!='closed'", (npub,)).fetchone()[0],
|
||
"payments": conn.execute("SELECT COUNT(*) FROM payments WHERE merchant_id=? AND status NOT IN ('acknowledged')", (npub,)).fetchone()[0],
|
||
"bookings": conn.execute("SELECT COUNT(*) FROM bookings WHERE merchant_id=? AND status NOT IN ('seated','closed','cancelled')", (npub,)).fetchone()[0],
|
||
}
|
||
current = security.session(conn, self.headers)
|
||
if rollout.effective_mode(conn, "GET", "/api/business", npub) == "enforce" and (not current or current["npub"] != npub):
|
||
counts = {key: 0 for key in counts}
|
||
images = list(dict.fromkeys([nsite.get("media", {}).get("heroImage"), *nsite.get("media", {}).get("galleryImages", []), *place.get("images", [])]))
|
||
images = [image for image in images if image and not any(marker in image.lower() for marker in ("logo", "icon", "static.cdninstagram.com"))]
|
||
merchant_name = nsite.get("name") or place.get("name")
|
||
query_string = urlencode({"q": merchant_name})
|
||
map_query_string = urlencode({"q": merchant_name, "place": place.get("slug")})
|
||
links = {
|
||
"justworks": place.get("justworks") or f"https://justworks.cash/{place.get('slug')}",
|
||
"tip": f"https://justworks.cash/tip/?{urlencode({'id': place.get('slug')})}",
|
||
"nsite": nsite.get("nsite", {}).get("gatewayUrl"),
|
||
"nostr": nsite.get("identity", {}).get("primalUrl") or f"https://njump.me/{npub}",
|
||
"maps": f"https://maps.justworks.cash/?{map_query_string}",
|
||
"gallery": f"https://gallery.justworks.cash/?{query_string}",
|
||
}
|
||
if self.is_local():
|
||
# Dev: keep merchants on local surfaces — /page renders the
|
||
# platform-hosted Just Works page, /site renders the nsite
|
||
# copy the product generates and publishes to Nostr.
|
||
links["justworks"] = f"{BASE}/page?npub={npub}"
|
||
links["nsite"] = f"{BASE}/site?npub={npub}"
|
||
links["gallery"] = f"{BASE}/gallery?{query_string}"
|
||
profile = nsite.get("identity", {}).get("profile", {})
|
||
lightning_address = str(profile.get("lud16") or "").strip()
|
||
payment_ready = bool(lightning_address and not lightning_address.lower().endswith("@npub.cash"))
|
||
if self.is_local() and not payment_ready:
|
||
# Dev only: stand in a working Lightning address so Pay / Pay & Go are unlocked.
|
||
lightning_address = DEV_LIGHTNING_ADDRESS
|
||
payment_ready = True
|
||
return self.send_json(200, {"merchant": {"name": merchant_name, "npub": npub, "slug": place.get("slug"), "area": place.get("area"), "category": place.get("category")}, "images": images[:8], "links": links, "profile": profile, "lightning_address": lightning_address, "payment_ready": payment_ready, "modules": modules, "counts": counts})
|
||
except ValueError as error: return self.send_json(400, {"error": str(error)})
|
||
except Exception as error: return self.send_json(502, {"error": f"merchant unavailable: {error}"})
|
||
if path == "/api/nsite-page":
|
||
npub = (query.get("npub") or [""])[0].strip()
|
||
try:
|
||
place, nsite = resolve_merchant_npub(npub)
|
||
if not place: return self.send_json(404, {"error": "merchant npub not found"})
|
||
sections = nsite.get("page", {}).get("sections", [])
|
||
menu = next((section for section in sections if section.get("type") == "menu" and section.get("enabled")), {})
|
||
products = [{"id": str(item.get("id") or f"item-{index}")[:40], "name": str(item.get("title") or item.get("name") or "Item")[:80], "description": str(item.get("body") or item.get("description") or "")[:180], "price": str(item.get("price") or "")[:30]} for index, item in enumerate(menu.get("items", [])[:60])]
|
||
assets = [item.get("url") for item in nsite.get("page", {}).get("assets", []) if item.get("url")]
|
||
sources = [image for source in nsite.get("sources", []) for image in source.get("images", [])]
|
||
media = nsite.get("media", {})
|
||
images = [image for image in dict.fromkeys([media.get("heroImage"), *media.get("galleryImages", []), *assets, *sources, *place.get("images", [])]) if image and not any(marker in image.lower() for marker in ("logo", "icon", "static.cdninstagram.com"))][:12]
|
||
profile = nsite.get("identity", {}).get("profile", {})
|
||
sources = [{"name": str(source.get("title") or source.get("host") or "Source")[:80], "host": str(source.get("host") or "")[:60], "url": str(source.get("url") or "")[:300], "description": str(source.get("description") or "")[:180], "photos": len(source.get("images", []))} for source in nsite.get("sources", [])][:6]
|
||
return self.send_json(200, {
|
||
"merchant": {"name": nsite.get("name") or place.get("name"), "npub": npub, "slug": place.get("slug"), "area": place.get("area"), "category": place.get("category"), "address": place.get("address"), "hours": place.get("hours"), "phone": place.get("phone"), "website": place.get("website"), "tags": [tag for tag in place.get("tags", [])[:8] if tag], "rating": place.get("rating")},
|
||
"about": str(profile.get("about") or "").strip()[:600],
|
||
"products": products,
|
||
"images": images,
|
||
"sources": sources,
|
||
"gateway": nsite.get("nsite", {}).get("gatewayUrl", ""),
|
||
"links": {"business": f"{BASE}/dashboard?npub={npub}", "page": f"{BASE}/page?npub={npub}"},
|
||
})
|
||
except ValueError as error: return self.send_json(400, {"error": str(error)})
|
||
except Exception as error: return self.send_json(502, {"error": f"site unavailable: {error}"})
|
||
if path == "/api/qr":
|
||
text = (query.get("text") or [""])[0].strip()
|
||
if not text or len(text) > 500: return self.send_json(400, {"error": "provide qr text"})
|
||
try:
|
||
raw = helper({"action": "qr", "text": text})["svg"].encode()
|
||
self.send_response(200)
|
||
self.send_header("Content-Type", "image/svg+xml")
|
||
self.send_header("Cache-Control", "public,max-age=3600")
|
||
self.send_header("Content-Length", str(len(raw)))
|
||
self.end_headers()
|
||
self.wfile.write(raw)
|
||
return
|
||
except Exception as error: return self.send_json(502, {"error": f"qr unavailable: {error}"})
|
||
if path == "/api/gallery":
|
||
try:
|
||
payload = fetch_json("https://justworks.cash/data/regions/madeira.json")
|
||
return self.send_json(200, {"name": payload.get("name", "Madeira"), "places": payload.get("places", [])})
|
||
except Exception as error:
|
||
return self.send_json(502, {"error": f"gallery data unavailable: {error}"})
|
||
if path == "/api/screen":
|
||
npub = (query.get("npub") or [""])[0].strip()
|
||
try:
|
||
npub = security.canonical_npub(npub)
|
||
place, nsite = resolve_merchant_npub(npub)
|
||
if not place: return self.send_json(404, {"error": "merchant npub not found"})
|
||
screen_activity.configure(npub, nsite)
|
||
assets = [item.get("url") for item in nsite.get("page", {}).get("assets", []) if item.get("url")]
|
||
sources = [image for source in nsite.get("sources", []) for image in source.get("images", [])]
|
||
images = list(dict.fromkeys([nsite.get("media", {}).get("heroImage"), *nsite.get("media", {}).get("galleryImages", []), *assets, *sources, *place.get("images", [])]))
|
||
images = [image for image in images if image and not any(marker in image.lower() for marker in ("static.cdninstagram.com", "facebook.com/images", "logo", "icon"))][:18]
|
||
images.sort(key=lambda image: (image.lower().split("?", 1)[0].endswith(".png"), "folha" in image.lower()))
|
||
order_url = f"http://{self.headers.get('Host', '127.0.0.1:4310')}{BASE}/order?npub={npub}" if self.is_local() else f"https://order.justworks.cash/?npub={npub}"
|
||
justpay_url = f"http://{self.headers.get('Host', '127.0.0.1:4310')}{BASE}/portal?{urlencode({'surface': 'payandgo', 'npub': npub})}" if self.is_local() else f"https://payandgo.justworks.cash/?npub={npub}"
|
||
tip_url = f"https://justworks.cash/tip/?{urlencode({'id': place['slug']})}"
|
||
qr = helper({"action": "qr", "text": order_url})["svg"]
|
||
try: latest = helper({"action": "latest", "npub": npub, "relays": PAYMENT_RELAYS})["event"]
|
||
except Exception: latest = None
|
||
return self.send_json(200, {"merchant": {"name": nsite.get("name") or place.get("name"), "npub": npub, "slug": place.get("slug"), "area": place.get("area"), "category": place.get("category")}, "images": images, "latest": latest, "order_url": order_url, "qr_svg": qr, "screen_links": {"order": order_url, "justpay": justpay_url, "tip": tip_url}})
|
||
except ValueError as error:
|
||
return self.send_json(400, {"error": str(error)})
|
||
except Exception as error:
|
||
return self.send_json(502, {"error": f"screen data unavailable: {error}"})
|
||
if path == "/api/screen-activity":
|
||
try:
|
||
npub = security.canonical_npub((query.get('npub') or [''])[0].strip())
|
||
except ValueError:
|
||
return self.send_json(400, {'error': 'provide a valid merchant npub'})
|
||
with LOCK, db() as conn:
|
||
payload = screen_activity.projection(conn, npub, screen_tips)
|
||
return self.send_json(200, payload)
|
||
if path == "/api/order-merchant":
|
||
npub = (query.get("npub") or [""])[0].strip()
|
||
try:
|
||
place, nsite = resolve_merchant_npub(npub)
|
||
if not place: return self.send_json(404, {"error": "merchant npub not found"})
|
||
sections = nsite.get("page", {}).get("sections", [])
|
||
menu = next((section for section in sections if section.get("type") == "menu" and section.get("enabled")), {})
|
||
products = [{"id": str(item.get("id") or f"item-{index}")[:40], "name": str(item.get("title") or item.get("name") or "Item")[:80], "description": str(item.get("body") or item.get("description") or "")[:180], "price": str(item.get("price") or "")[:30]} for index, item in enumerate(menu.get("items", [])[:50])]
|
||
images = list(dict.fromkeys([nsite.get("media", {}).get("heroImage"), *nsite.get("media", {}).get("galleryImages", []), *place.get("images", [])]))
|
||
images = [image for image in images if image and not any(marker in image.lower() for marker in ("logo", "icon", "static.cdninstagram.com"))]
|
||
images.sort(key=lambda image: image.lower().split("?", 1)[0].endswith(".png"))
|
||
return self.send_json(200, {"merchant": {"name": nsite.get("name") or place.get("name"), "npub": npub, "slug": place.get("slug"), "area": place.get("area"), "category": place.get("category")}, "images": images[:8], "products": products})
|
||
except ValueError as error: return self.send_json(400, {"error": str(error)})
|
||
except Exception as error: return self.send_json(502, {"error": f"merchant unavailable: {error}"})
|
||
if path == "/api/merchant-order-status":
|
||
npub = (query.get("npub") or [""])[0].strip()
|
||
order_id = (query.get("id") or [""])[0].strip()
|
||
if not npub.startswith("npub1") or len(npub) > 100 or not order_id:
|
||
return self.send_json(400, {"error": "provide the merchant and order"})
|
||
with LOCK, db() as conn:
|
||
row = conn.execute("SELECT id,table_number,status,updated_at FROM orders WHERE id=? AND merchant_id=?", (order_id, npub)).fetchone()
|
||
if not row: return self.send_json(404, {"error": "order not found"})
|
||
return self.send_json(200, dict(row))
|
||
if path == "/api/quick-pay-status":
|
||
npub = (query.get("npub") or [""])[0].strip()
|
||
payment_id = (query.get("id") or [""])[0].strip()
|
||
if not npub.startswith("npub1") or len(npub) > 100 or len(payment_id) != 32:
|
||
return self.send_json(400, {"error": "provide the merchant and payment"})
|
||
with LOCK, db() as conn:
|
||
row = conn.execute("SELECT * FROM quick_payments WHERE id=? AND merchant_id=?", (payment_id, npub)).fetchone()
|
||
if not row: return self.send_json(404, {"error": "payment not found"})
|
||
payment = dict(row)
|
||
if payment["status"] == "pending" and payment["zap_pubkey"] and payment["recipient"]:
|
||
try:
|
||
created_at = int(datetime.fromisoformat(payment["created_at"]).timestamp())
|
||
check = helper({"action": "check", "relays": PAYMENT_RELAYS, "recipient": payment["recipient"],
|
||
"pubkey": payment["zap_pubkey"], "since": created_at})
|
||
if check["paid"]:
|
||
settled_at = now()
|
||
with LOCK, db() as conn:
|
||
conn.execute("UPDATE quick_payments SET status='paid',receipt_id=?,settled_at=? WHERE id=?",
|
||
(check["receipt"], settled_at, payment_id))
|
||
conn.commit()
|
||
payment.update(status="paid", receipt_id=check["receipt"], settled_at=settled_at)
|
||
except Exception:
|
||
pass
|
||
fields = ("id", "merchant_name", "amount_cents", "amount_sats", "status", "receipt_id", "created_at", "settled_at")
|
||
return self.send_json(200, {key: payment[key] for key in fields} | {"verifiable": bool(payment["zap_pubkey"])})
|
||
if path == "/api/pay-orders":
|
||
# Existing customer receipt discovery, without staff notes, guest bookings or payments.
|
||
npub = (query.get("npub") or [""])[0].strip()
|
||
try: security.canonical_npub(npub)
|
||
except ValueError: return self.send_json(400, {"error": "provide a valid npub"})
|
||
with LOCK, db() as conn:
|
||
orders = [{"table_number": row["table_number"], "status": row["status"],
|
||
"items": [{key: item.get(key) for key in ("name", "qty", "price_cents")} for item in json.loads(row["items_json"])]}
|
||
for row in conn.execute("SELECT table_number,status,items_json FROM orders WHERE merchant_id=? AND status!='closed' ORDER BY created_at", (npub,))]
|
||
return self.send_json(200, {"orders": orders})
|
||
if path == "/api/pos":
|
||
npub = (query.get("npub") or [""])[0].strip()
|
||
if not npub.startswith("npub1") or len(npub) > 100:
|
||
return self.send_json(400, {"error": "provide a valid npub"})
|
||
with LOCK, db() as conn:
|
||
orders = [dict(row) | {"items": json.loads(row["items_json"])} for row in conn.execute("SELECT * FROM orders WHERE merchant_id=? AND status!='closed' ORDER BY created_at", (npub,))]
|
||
for item in orders: item.pop("items_json", None)
|
||
payments = [dict(row) for row in conn.execute("SELECT id,order_id,table_number,amount_cents,amount_sats,status,created_at,settled_at FROM payments WHERE merchant_id=? AND status!='acknowledged' ORDER BY created_at", (npub,))]
|
||
bookings = [dict(row) for row in conn.execute("SELECT * FROM bookings WHERE merchant_id=? AND status NOT IN ('closed','cancelled') ORDER BY scheduled_at", (npub,))]
|
||
return self.send_json(200, {"orders": orders, "payments": payments, "bookings": bookings, "server_time": now()})
|
||
if path == "/api/table":
|
||
token = (query.get("token") or [""])[0]
|
||
with LOCK, db() as conn:
|
||
row = conn.execute("SELECT table_number FROM tables WHERE token=?", (token,)).fetchone()
|
||
if not row: return self.send_json(404, {"error": "invalid table token"})
|
||
config = merchant_payload(conn)
|
||
if not config["module_enabled"]: return self.send_json(403, {"error": "module disabled"})
|
||
return self.send_json(200, {"merchant": config["name"], "table": row["table_number"], "products": [p for p in config["products"] if p["enabled"]]})
|
||
if path == "/api/order":
|
||
token = (query.get("token") or [""])[0]
|
||
order_id = (query.get("id") or [""])[0]
|
||
with LOCK, db() as conn:
|
||
row = conn.execute("""SELECT o.id,o.table_number,o.status,o.updated_at
|
||
FROM orders o JOIN tables t ON t.merchant_id=o.merchant_id AND t.table_number=o.table_number
|
||
WHERE o.id=? AND t.token=?""", (order_id, token)).fetchone()
|
||
if not row: return self.send_json(404, {"error": "order not found"})
|
||
return self.send_json(200, dict(row))
|
||
if path == "/api/payment":
|
||
token = (query.get("token") or [""])[0]
|
||
payment_id = (query.get("id") or [""])[0]
|
||
with LOCK, db() as conn:
|
||
row = conn.execute("""SELECT p.* FROM payments p JOIN tables t ON t.merchant_id=p.merchant_id AND t.table_number=p.table_number
|
||
WHERE p.id=? AND t.token=?""", (payment_id, token)).fetchone()
|
||
if not row: return self.send_json(404, {"error": "payment not found"})
|
||
payment = dict(row)
|
||
if payment["status"] == "pending":
|
||
check = helper({"action": "check", "relays": PAYMENT_RELAYS, "recipient": PAYMENT_RECIPIENT, "pubkey": payment["zap_pubkey"], "since": int(datetime.fromisoformat(payment["created_at"]).timestamp())})
|
||
if check["paid"]:
|
||
with LOCK, db() as conn:
|
||
conn.execute("UPDATE payments SET status='paid',receipt_id=?,settled_at=? WHERE id=?", (check["receipt"], now(), payment_id))
|
||
conn.commit()
|
||
payment["status"], payment["receipt_id"], payment["settled_at"] = "paid", check["receipt"], now()
|
||
return self.send_json(200, {key: payment[key] for key in ("id", "order_id", "table_number", "amount_cents", "amount_sats", "bolt11", "status", "receipt_id", "created_at", "settled_at")})
|
||
if path == "/napplet/order-to-table/index.html": return self.static(NAPLET_DIST / "index.html", "public,max-age=300")
|
||
if path == "/assets/examples/localpub-venue.webp": return self.redirect("https://justworks.cash/assets/examples/localpub-venue.webp")
|
||
if path == "/nsite": return self.nsite()
|
||
if path == "/business-sso": return self.static(PUBLIC / "business-sso.html")
|
||
if path in ("/pos-sw.js", "/screen-sw.js"):
|
||
return self.static(PUBLIC / path[1:], "no-cache")
|
||
if path in ("/pos.webmanifest", "/screen.webmanifest"):
|
||
return self.app_manifest(path[1:].split(".")[0], query)
|
||
surface_pages = {
|
||
"business": "dashboard.html", "pos": "pos.html", "order": "order.html", "book": "book.html",
|
||
"pay": "portal.html", "payandgo": "portal.html", "screen": "screen.html",
|
||
"gallery": "gallery.html", "events": "portal.html",
|
||
}
|
||
pages = {"/qr-sheet": "qr-sheet.html", "/": surface_pages.get(self.surface(), "dashboard.html"), "/dashboard": "dashboard.html", "/profile": "profile.html", "/stats": "stats.html", "/embed": "shell.html", "/portal": "portal.html", "/gallery": "gallery.html", "/screen": "screen.html", "/order": "order.html", "/book": "book.html", "/pos": "pos.html", "/site": "site.html", "/page": "page.html"}
|
||
if path in pages: return self.static(PUBLIC / pages[path])
|
||
if path.startswith("/assets/"): return self.static(PUBLIC / path.removeprefix("/"), "public,max-age=3600")
|
||
if path.startswith("/fonts/"): return self.static(PUBLIC / path.removeprefix("/"), "public,max-age=86400")
|
||
self.send_error(404)
|
||
|
||
def do_POST(self):
|
||
self.session_cookie = self.exchange_cookie = self.authorized_mutation = None
|
||
path, _ = self.route()
|
||
try: body = self.body()
|
||
except Exception as error: return self.send_json(400, {"error": str(error)})
|
||
if path.startswith("/api/business-sso/"):
|
||
if not rollout.enabled(): return self.send_json(404, {"error": "Session exchange is not enabled"})
|
||
with LOCK, db() as conn:
|
||
if not security.rate_allowed(self.client_address[0], "/api/business-owner-login", {}, self.headers):
|
||
return self.send_json(429, {"error": "Too many exchange requests"})
|
||
try:
|
||
if path == "/api/business-sso/start":
|
||
result, self.exchange_cookie = rollout.start(conn, body, self.headers)
|
||
elif path in {"/api/business-sso/details", "/api/business-sso/approve"}:
|
||
result = rollout.source_details(conn, body, self.headers, path.endswith("approve"))
|
||
elif path == "/api/business-sso/complete":
|
||
result, self.session_cookie = rollout.complete(conn, body, self.headers)
|
||
self.exchange_cookie = rollout.exchange_cookie()
|
||
else: return self.send_json(404, {"error": "Unknown session action"})
|
||
return self.send_json(200, result)
|
||
except (ValueError, KeyError, CookieError):
|
||
security.audit(conn, "exchange_denied", path, reason="invalid_or_expired_exchange")
|
||
return self.send_json(403, {"error": "Session exchange could not be verified; log in and try again"})
|
||
self.authorized_mutation = None
|
||
self.session_cookie = None
|
||
if path in {"/api/business-owner-login", "/api/orders", "/api/merchant-orders", "/api/bookings", "/api/payments", "/api/quick-pay"}:
|
||
with LOCK:
|
||
if not security.rate_allowed(self.client_address[0], path, body, self.headers):
|
||
return self.send_json(429, {"error": "Too many requests; try again shortly"})
|
||
if path in {"/api/business-owner-login", "/api/business-logout", "/api/business-revoke"} and not security.same_origin(self.headers):
|
||
return self.send_json(403, {"error": "Same-origin request required"})
|
||
with LOCK, db() as conn:
|
||
if path in {"/api/business-logout", "/api/business-revoke"}:
|
||
current = security.revoke(conn, self.headers, path.endswith("revoke"))
|
||
security.audit(conn, "logout", path, actor=current["npub"] if current else "")
|
||
self.session_cookie = security.cookie_header()
|
||
return self.send_json(200, {"ok": True})
|
||
allowed, context = security.authorize(conn, "POST", path, body, self.headers)
|
||
if not allowed: return self.send_json(403, {"error": "Merchant session required for this account"})
|
||
self.authorized_mutation = context
|
||
if path == "/api/config":
|
||
with LOCK, db() as conn:
|
||
enabled = 1 if body.get("module_enabled") else 0
|
||
count = max(1, min(100, int(body.get("table_count", 30))))
|
||
legacy = rollout.legacy_merchant(conn)
|
||
conn.execute("UPDATE merchant SET module_enabled=?,table_count=?,updated_at=? WHERE id=?", (enabled, count, now(), legacy))
|
||
products = body.get("products") or []
|
||
for index, product in enumerate(products[:50]):
|
||
pid = str(product.get("id") or f"item-{index}")[:40]
|
||
name = str(product.get("name") or "Item")[:80]
|
||
cents = max(0, min(1000000, int(product.get("price_cents", 0))))
|
||
conn.execute("INSERT INTO products VALUES (?, ?, ?, ?, 1, ?) ON CONFLICT(id) DO UPDATE SET name=excluded.name,price_cents=excluded.price_cents,sort_order=excluded.sort_order WHERE products.merchant_id=excluded.merchant_id", (pid, legacy, name, cents, index))
|
||
for table in range(1, count + 1): conn.execute("INSERT OR IGNORE INTO tables VALUES (?, ?, ?)", (legacy, table, secrets.token_urlsafe(18)))
|
||
for module_id, value in (body.get("modules") or {}).items():
|
||
if module_id not in MODULE_DEFAULTS: continue
|
||
enabled = 1 if value.get("enabled") else 0
|
||
config = json.dumps(value.get("config") or {}, separators=(",", ":"))
|
||
conn.execute("UPDATE merchant_modules SET enabled=?,config_json=?,updated_at=? WHERE merchant_id=? AND module_id=?", (enabled, config, now(), legacy, module_id))
|
||
conn.commit()
|
||
return self.send_json(200, merchant_payload(conn, True))
|
||
if path == "/api/business-config":
|
||
npub = str(body.get("npub", "")).strip()
|
||
try:
|
||
place, _ = resolve_merchant_npub(npub)
|
||
if not place: return self.send_json(404, {"error": "merchant npub not found"})
|
||
except ValueError as error: return self.send_json(400, {"error": str(error)})
|
||
except Exception as error: return self.send_json(502, {"error": f"merchant unavailable: {error}"})
|
||
requested = body.get("modules") or {}
|
||
with LOCK, db() as conn:
|
||
merchant_modules(conn, npub)
|
||
for module_id, value in requested.items():
|
||
if module_id not in MODULE_DEFAULTS or MODULE_DEFAULTS[module_id][1] == "coming-soon": continue
|
||
conn.execute("UPDATE merchant_modules SET enabled=?,updated_at=? WHERE merchant_id=? AND module_id=?", (1 if value else 0, now(), npub, module_id))
|
||
conn.commit()
|
||
return self.send_json(200, {"ok": True, "modules": merchant_modules(conn, npub)})
|
||
if path == "/api/business-owner-login":
|
||
npub = str(body.get("npub", "")).strip()
|
||
try:
|
||
place, _ = resolve_merchant_npub(npub)
|
||
if not place: raise ValueError("merchant_not_found")
|
||
if os.environ.get("JWB_ALLOW_DEMO_LOGIN") == "1" and security.mode() != "enforce" and str(body.get("ownerIdentifier", "")).strip().lower() == "demo":
|
||
# Dev shortcut: prefilled demo login resolves locally without remote credentials.
|
||
return self.send_json(200, {"ok": True, "merchant": place.get("name"), "npub": npub, "dev": True})
|
||
credentials = {key: str(body.get(key, "")).strip() for key in ("ownerKey", "ownerIdentifier", "ownerPassword") if body.get(key)}
|
||
if not credentials.get("ownerKey") and not (credentials.get("ownerIdentifier") and credentials.get("ownerPassword")):
|
||
raise ValueError("credentials_required")
|
||
result = remote_json(f"https://justworks.cash/api/nsites/{place['slug']}/login", "POST", credentials)
|
||
owner = result.get("nsite", {})
|
||
if owner.get("npub") != npub:
|
||
raise ValueError("ownership_mismatch")
|
||
with LOCK, db() as conn:
|
||
self.session_cookie, expires = security.issue(conn, npub, self.headers)
|
||
rollout.verification(conn, npub, place['slug'])
|
||
security.audit(conn, "login_success", path, npub, npub)
|
||
return self.send_json(200, {"ok": True, "merchant": owner.get("name") or place.get("name"), "npub": npub, "expires_at": expires})
|
||
except Exception:
|
||
self.session_cookie = None
|
||
with LOCK, db() as conn: security.audit(conn, "login_failure", path, reason="verification_failed")
|
||
return self.send_json(401, {"error": "Just Works login could not be verified"})
|
||
if path == "/api/business-profile":
|
||
npub = str(body.get("npub", "")).strip()
|
||
try:
|
||
place, _ = resolve_merchant_npub(npub)
|
||
if not place: return self.send_json(404, {"error": "merchant npub not found"})
|
||
profile = body.get("profile") or {}
|
||
lightning_address = str(profile.get("lud16", "")).strip().lower()
|
||
if not lightning_address or "@" not in lightning_address or lightning_address.endswith("@npub.cash"):
|
||
return self.send_json(400, {"error": "Enter the merchant’s own Lightning address, not the default npub.cash address"})
|
||
credentials = {key: str(body.get(key, "")).strip() for key in ("ownerKey", "ownerIdentifier", "ownerPassword") if body.get(key)}
|
||
if not credentials.get("ownerKey") and not (credentials.get("ownerIdentifier") and credentials.get("ownerPassword")):
|
||
return self.send_json(400, {"error": "Enter the Just Works owner login or owner key"})
|
||
if os.environ.get("JWB_ALLOW_DEMO_LOGIN") == "1" and security.mode() != "enforce" and credentials.get("ownerIdentifier", "").lower() == "demo":
|
||
# Dev shortcut: accept the save locally without republishing the production nsite.
|
||
dev_profile = {"display_name": str(profile.get("display_name", ""))[:120], "about": str(profile.get("about", ""))[:1200], "picture": str(profile.get("picture", ""))[:1000], "banner": str(profile.get("banner", ""))[:1000], "lud16": lightning_address, "website": place.get("justworks") or ""}
|
||
return self.send_json(200, {"ok": True, "profile": dev_profile, "published": True, "dev": True})
|
||
update = {
|
||
**credentials,
|
||
"name": str(profile.get("display_name", ""))[:120],
|
||
"note": str(profile.get("about", ""))[:1200],
|
||
"payment": lightning_address,
|
||
"avatarImage": str(profile.get("picture", ""))[:1000],
|
||
"heroImage": str(profile.get("banner", ""))[:1000],
|
||
"stage": "Business profile updated",
|
||
}
|
||
remote_json(f"https://justworks.cash/api/nsites/{place['slug']}", "PATCH", update)
|
||
published = remote_json(f"https://justworks.cash/api/nsites/{place['slug']}/deploy", "POST", credentials, 90)
|
||
return self.send_json(200, {"ok": True, "profile": published.get("nsite", {}).get("identity", {}).get("profile", {}), "published": True})
|
||
except ValueError as error: return self.send_json(400, {"error": str(error)})
|
||
except Exception as error: return self.send_json(502, {"error": f"profile unavailable: {error}"})
|
||
if path == "/api/orders":
|
||
token, items = str(body.get("token", "")), body.get("items") or []
|
||
note = str(body.get("note", ""))[:240]
|
||
with LOCK, db() as conn:
|
||
table = conn.execute("SELECT table_number,merchant_id FROM tables WHERE token=?", (token,)).fetchone()
|
||
if not table: return self.send_json(404, {"error": "invalid table token"})
|
||
merchant = conn.execute("SELECT module_enabled FROM merchant WHERE id=?", (table['merchant_id'],)).fetchone()
|
||
if not merchant["module_enabled"]: return self.send_json(403, {"error": "module disabled"})
|
||
clean = []
|
||
for item in items[:20]:
|
||
product = conn.execute("SELECT id,name,price_cents FROM products WHERE id=? AND merchant_id=? AND enabled=1", (str(item.get("id", "")), table['merchant_id'])).fetchone()
|
||
qty = max(0, min(20, int(item.get("qty", 0))))
|
||
if product and qty: clean.append(dict(product) | {"qty": qty})
|
||
if not clean: return self.send_json(400, {"error": "order is empty"})
|
||
oid, timestamp = secrets.token_hex(5), now()
|
||
conn.execute("INSERT INTO orders VALUES (?, ?, ?, ?, ?, 'new', ?, ?)", (oid, table['merchant_id'], table["table_number"], json.dumps(clean), note, timestamp, timestamp))
|
||
conn.commit()
|
||
total_cents = sum(item["price_cents"] * item["qty"] for item in clean)
|
||
return self.send_json(201, {"id": oid, "table": table["table_number"], "status": "new", "total_cents": total_cents})
|
||
if path == "/api/merchant-orders":
|
||
npub = str(body.get("npub", "")).strip()
|
||
request_text = str(body.get("request", "")).strip()[:500]
|
||
guest = str(body.get("guest", "")).strip()[:80]
|
||
try: table_number = max(1, min(999, int(body.get("table", 1))))
|
||
except (TypeError, ValueError): return self.send_json(400, {"error": "enter a valid table number"})
|
||
selected = body.get("items") or []
|
||
if not request_text and not selected: return self.send_json(400, {"error": "add an order or request"})
|
||
try:
|
||
place, nsite = resolve_merchant_npub(npub)
|
||
if not place: return self.send_json(404, {"error": "merchant npub not found"})
|
||
except ValueError as error: return self.send_json(400, {"error": str(error)})
|
||
except Exception as error: return self.send_json(502, {"error": f"merchant unavailable: {error}"})
|
||
items = [{"id": str(item.get("id", "item"))[:40], "name": str(item.get("name", "Item"))[:80], "qty": max(1, min(20, int(item.get("qty", 1)))), "price_cents": 0} for item in selected[:30]]
|
||
if request_text: items.append({"id": "request", "name": request_text, "qty": 1, "price_cents": 0})
|
||
oid, timestamp = secrets.token_hex(5), now()
|
||
note = f"Guest: {guest}" if guest else ""
|
||
with LOCK, db() as conn:
|
||
conn.execute("INSERT INTO orders VALUES (?,?,?,?,?,'new',?,?)", (oid, npub, table_number, json.dumps(items), note, timestamp, timestamp))
|
||
conn.commit()
|
||
return self.send_json(201, {"id": oid, "merchant": nsite.get("name") or place.get("name"), "table": table_number, "status": "new", "created_at": timestamp})
|
||
if path == "/api/bookings":
|
||
npub = str(body.get("npub", "")).strip()
|
||
name = str(body.get("name", "")).strip()[:80]
|
||
notes = str(body.get("notes", "")).strip()[:240]
|
||
mode = str(body.get("mode", "later"))
|
||
try: guests = max(1, min(20, int(body.get("guests", 2))))
|
||
except (TypeError, ValueError): return self.send_json(400, {"error": "enter a valid guest count"})
|
||
if not name: return self.send_json(400, {"error": "tell the merchant who is booking"})
|
||
if mode not in {"now", "later"}: return self.send_json(400, {"error": "choose now or later"})
|
||
try:
|
||
scheduled = datetime.fromisoformat(str(body.get("scheduled_at", "")).replace("Z", "+00:00"))
|
||
if scheduled.tzinfo is None: scheduled = scheduled.replace(tzinfo=timezone.utc)
|
||
current = datetime.now(timezone.utc)
|
||
if scheduled < current.replace(microsecond=0) and (current - scheduled).total_seconds() > 120: return self.send_json(400, {"error": "choose a future time"})
|
||
if scheduled < current: scheduled = current
|
||
scheduled_at = scheduled.astimezone(timezone.utc).isoformat(timespec="seconds")
|
||
place, nsite = resolve_merchant_npub(npub)
|
||
if not place: return self.send_json(404, {"error": "merchant npub not found"})
|
||
except ValueError as error: return self.send_json(400, {"error": str(error) or "choose a valid time"})
|
||
except Exception as error: return self.send_json(502, {"error": f"merchant unavailable: {error}"})
|
||
booking_id, timestamp = secrets.token_hex(6), now()
|
||
with LOCK, db() as conn:
|
||
conn.execute("INSERT INTO bookings VALUES (?,?,?,?,?,?,?,'new',?,?)", (booking_id, npub, name, guests, scheduled_at, mode, notes, timestamp, timestamp))
|
||
conn.commit()
|
||
return self.send_json(201, {"id": booking_id, "merchant": nsite.get("name") or place.get("name"), "name": name, "guests": guests, "scheduled_at": scheduled_at, "status": "new"})
|
||
if path == "/api/quick-pay":
|
||
npub = str(body.get("npub", "")).strip()
|
||
try:
|
||
amount_cents = int(body.get("amount_cents", 0))
|
||
if amount_cents < 1 or amount_cents > 10_000_000:
|
||
raise ValueError("Enter an amount between €0.01 and €100,000")
|
||
place, nsite = resolve_merchant_npub(npub)
|
||
if not place: return self.send_json(404, {"error": "merchant npub not found"})
|
||
lightning_address = str(nsite.get("identity", {}).get("profile", {}).get("lud16") or "").strip()
|
||
if not lightning_address: raise ValueError("This merchant has no Lightning address")
|
||
prices = fetch_json("https://mempool.space/api/v1/prices")
|
||
amount_sats = max(1, round(amount_cents * 1_000_000 / int(prices["EUR"])))
|
||
merchant_name = nsite.get("name") or place.get("name")
|
||
invoice = lightning_invoice(lightning_address, amount_sats * 1000, str(body.get("comment", "")))
|
||
payment_id, created_at = secrets.token_hex(16), now()
|
||
with LOCK, db() as conn:
|
||
conn.execute("INSERT INTO quick_payments VALUES (?,?,?,?,?,?,?,?,?,?,?,?)",
|
||
(payment_id, npub, merchant_name, amount_cents, amount_sats, invoice["bolt11"],
|
||
invoice["zap_pubkey"], invoice["recipient"], "pending", None, created_at, None))
|
||
conn.commit()
|
||
qr_svg = helper({"action": "qr", "text": invoice["bolt11"]})["svg"]
|
||
return self.send_json(201, {"id": payment_id, "merchant": merchant_name, "amount_cents": amount_cents,
|
||
"amount_sats": amount_sats, "bolt11": invoice["bolt11"], "qr_svg": qr_svg,
|
||
"status": "pending", "verifiable": invoice["verifiable"], "created_at": created_at})
|
||
except ValueError as error: return self.send_json(400, {"error": str(error)})
|
||
except HTTPError as error: return self.send_json(502, {"error": f"Lightning provider rejected the request ({error.code})"})
|
||
except Exception as error: return self.send_json(502, {"error": f"Invoice unavailable: {error}"})
|
||
if path == "/api/payments" and os.environ.get("JWB_ARCHIPELAGO") == "1":
|
||
return self.send_json(503, {"error": "Legacy order checkout is not connected to your wallet. Use the merchant website payment page."})
|
||
if path == "/api/payments":
|
||
token, order_id = str(body.get("token", "")), str(body.get("order_id", ""))
|
||
with LOCK, db() as conn:
|
||
row = conn.execute("""SELECT o.*,t.token FROM orders o JOIN tables t ON t.merchant_id=o.merchant_id AND t.table_number=o.table_number
|
||
WHERE o.id=? AND t.token=?""", (order_id, token)).fetchone()
|
||
if not row: return self.send_json(404, {"error": "order not found"})
|
||
existing = conn.execute("SELECT * FROM payments WHERE order_id=? ORDER BY created_at DESC LIMIT 1", (order_id,)).fetchone()
|
||
if existing and existing["status"] == "pending": return self.send_json(200, dict(existing))
|
||
items = json.loads(row["items_json"])
|
||
amount_cents = sum(item["price_cents"] * item["qty"] for item in items)
|
||
prices = fetch_json("https://mempool.space/api/v1/prices")
|
||
amount_sats = max(1, round(amount_cents * 1_000_000 / int(prices["EUR"])))
|
||
created_at = now()
|
||
zap = helper({"action": "create", "lnurlUrl": LNURL_URL, "relays": PAYMENT_RELAYS, "amountMsat": amount_sats * 1000, "recipient": PAYMENT_RECIPIENT, "table": row["table_number"]})
|
||
params = urlencode({"amount": amount_sats * 1000, "nostr": json.dumps(zap["event"], separators=(",", ":")), "lnurl": zap["lnurl"], "comment": f"Just Works Table {row['table_number']}"})
|
||
invoice = fetch_json(f"{LNURL_CALLBACK}?{params}")
|
||
if invoice.get("status") == "ERROR" or not invoice.get("pr"): return self.send_json(502, {"error": invoice.get("reason", "invoice unavailable")})
|
||
payment_id = secrets.token_hex(6)
|
||
with LOCK, db() as conn:
|
||
conn.execute("INSERT INTO payments VALUES (?,?,?,?,?,?,?,?,?,?,?,?)", (payment_id, order_id, row["merchant_id"], row["table_number"], amount_cents, amount_sats, invoice["pr"], zap["pubkey"], "pending", None, created_at, None))
|
||
conn.commit()
|
||
return self.send_json(201, {"id": payment_id, "order_id": order_id, "table_number": row["table_number"], "amount_cents": amount_cents, "amount_sats": amount_sats, "bolt11": invoice["pr"], "status": "pending"})
|
||
if path.startswith("/api/orders/") and path.endswith("/status"):
|
||
oid = path.split("/")[3]
|
||
status = str(body.get("status", ""))
|
||
if status not in {"new", "accepted", "delivered", "closed"}: return self.send_json(400, {"error": "invalid status"})
|
||
with LOCK, db() as conn:
|
||
changed = conn.execute("UPDATE orders SET status=?,updated_at=? WHERE id=?", (status, now(), oid)).rowcount
|
||
conn.commit()
|
||
return self.send_json(200 if changed else 404, {"ok": bool(changed), "status": status})
|
||
if path.startswith("/api/payments/") and path.endswith("/acknowledge"):
|
||
payment_id = path.split("/")[3]
|
||
with LOCK, db() as conn:
|
||
changed = conn.execute("UPDATE payments SET status='acknowledged' WHERE id=? AND status='paid'", (payment_id,)).rowcount
|
||
conn.commit()
|
||
return self.send_json(200 if changed else 404, {"ok": bool(changed), "status": "acknowledged"})
|
||
if path.startswith("/api/bookings/") and path.endswith("/status"):
|
||
booking_id = path.split("/")[3]
|
||
status = str(body.get("status", ""))
|
||
if status not in {"new", "confirmed", "seated", "closed", "cancelled"}: return self.send_json(400, {"error": "invalid status"})
|
||
with LOCK, db() as conn:
|
||
changed = conn.execute("UPDATE bookings SET status=?,updated_at=? WHERE id=?", (status, now(), booking_id)).rowcount
|
||
conn.commit()
|
||
return self.send_json(200 if changed else 404, {"ok": bool(changed), "status": status})
|
||
self.send_error(404)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
security.mode()
|
||
rollout.policy()
|
||
rollout.origins()
|
||
with db(): pass
|
||
print(f"JustWorks Business listening on http://{HOST}:{PORT}{BASE}/")
|
||
ThreadingHTTPServer((HOST, PORT), Handler).serve_forever()
|