fix: production billing hardening + Stripe→Commerce migration

- Replace eval() with json.loads() in usage tracker (security fix)
- Webhook verification fails-closed in production when secret missing
- Add Commerce env vars to K8s deployment (URL, API key, webhook secret)
- Expose ZAP port 9999 in Dockerfile and K8s service
- Replace Stripe env vars with Square in compose.yml
- Update privacy/terms pages: Stripe→Square via Hanzo Commerce
- Add full billing API client module to web/lib/api.ts (17 endpoints)
- Fix hardcoded localhost URLs in dashboard/analytics/organization pages
- Add production webhook rejection test (57 tests passing)
This commit is contained in:
2026-02-12 11:22:03 -08:00
parent fa2bcb0455
commit 8edcaa614f
12 changed files with 84 additions and 25 deletions
+2 -2
View File
@@ -23,6 +23,6 @@ COPY bootnode/ bootnode/
# Hanzo SDKs are optional - in K8s, the KMS operator injects secrets as env vars
RUN uv pip install --system -e .
# Run the application
EXPOSE 8000
# Run the application (8000=HTTP API, 9999=ZAP Cap'n Proto RPC)
EXPOSE 8000 9999
CMD ["uvicorn", "bootnode.main:app", "--host", "0.0.0.0", "--port", "8000"]
+3 -3
View File
@@ -4,6 +4,7 @@ Tracks compute units in Redis for real-time rate limiting and quota checks.
Batches writes to ClickHouse for analytics and billing.
"""
import json
import time
import uuid
from datetime import UTC, datetime
@@ -102,7 +103,7 @@ class UsageTracker:
# Add to buffer and check if we should flush
buffer_key = CU_BUFFER_KEY.format(project_id=project_id)
try:
await redis_client.client.rpush(buffer_key, str(usage_record))
await redis_client.client.rpush(buffer_key, json.dumps(usage_record))
buffer_len = await redis_client.client.llen(buffer_key)
if buffer_len >= self._batch_size:
@@ -132,8 +133,7 @@ class UsageTracker:
records: list[dict[str, Any]] = []
for record_str in records_raw:
try:
# Convert string repr back to dict
record = eval(record_str) # Safe: we control the data
record = json.loads(record_str)
records.append(record)
except Exception:
continue
+5 -1
View File
@@ -61,7 +61,11 @@ class CommerceWebhookHandler:
True if signature is valid
"""
if not self.webhook_secret:
logger.warning("Webhook secret not configured, skipping verification")
settings = get_settings()
if settings.is_production:
logger.error("Webhook secret not configured in production — rejecting")
return False
logger.warning("Webhook secret not configured, skipping verification (dev mode)")
return True
expected = hmac.new(
+12 -3
View File
@@ -341,14 +341,23 @@ class TestCommerceClient:
class TestCommerceWebhooks:
@patch("bootnode.core.billing.webhooks.get_settings")
def test_no_webhook_secret_allows_through(self, mock_settings):
mock_settings.return_value = MagicMock(commerce_webhook_secret="")
def test_no_webhook_secret_allows_through_dev(self, mock_settings):
mock_settings.return_value = MagicMock(commerce_webhook_secret="", is_production=False)
from bootnode.core.billing.webhooks import CommerceWebhookHandler
handler = CommerceWebhookHandler()
# No secret = verification skipped (returns True)
# No secret in dev mode = verification skipped (returns True)
assert handler.verify_signature(b"payload", "any-sig") is True
@patch("bootnode.core.billing.webhooks.get_settings")
def test_no_webhook_secret_rejects_in_production(self, mock_settings):
mock_settings.return_value = MagicMock(commerce_webhook_secret="", is_production=True)
from bootnode.core.billing.webhooks import CommerceWebhookHandler
handler = CommerceWebhookHandler()
# No secret in production = fail-closed (returns False)
assert handler.verify_signature(b"payload", "any-sig") is False
@patch("bootnode.core.billing.webhooks.get_settings")
def test_valid_signature(self, mock_settings):
import hashlib
+2 -2
View File
@@ -464,8 +464,8 @@ services:
REDIS_URL: redis://redis:6379/3
DATASTORE_URL: http://datastore:8123
IAM_SERVER_URL: http://iam:8000
STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY:-}
STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET:-}
SQUARE_ACCESS_TOKEN: ${SQUARE_ACCESS_TOKEN:-}
SQUARE_WEBHOOK_SIGNATURE_KEY: ${SQUARE_WEBHOOK_SIGNATURE_KEY:-}
volumes:
- commerce_data:/app/data
ports:
+13
View File
@@ -31,7 +31,13 @@ stringData:
API_KEY_SALT: "your-secure-api-key-salt-change-in-production"
DATABASE_URL: "postgresql+asyncpg://bootnode:password@postgres:5432/bootnode"
REDIS_URL: "redis://redis:6379/0"
IAM_CLIENT_ID: "your-iam-client-id"
IAM_CLIENT_SECRET: "your-iam-client-secret"
# Hanzo Commerce (Square-based billing)
COMMERCE_URL: "https://commerce.hanzo.ai"
COMMERCE_API_KEY: "your-commerce-api-key"
COMMERCE_WEBHOOK_SECRET: "your-commerce-webhook-secret"
# Chain RPCs
ETH_MAINNET_RPC: "https://eth-mainnet.g.alchemy.com/v2/your-api-key"
POLYGON_MAINNET_RPC: "https://polygon-mainnet.g.alchemy.com/v2/your-api-key"
ARBITRUM_MAINNET_RPC: "https://arb-mainnet.g.alchemy.com/v2/your-api-key"
@@ -229,6 +235,9 @@ spec:
image: ghcr.io/hanzoai/bootnode:api-latest
ports:
- containerPort: 8000
name: http
- containerPort: 9999
name: zap
envFrom:
- configMapRef:
name: bootnode-config
@@ -265,6 +274,10 @@ spec:
ports:
- port: 80
targetPort: 8000
name: http
- port: 9999
targetPort: 9999
name: zap
type: ClusterIP
---
apiVersion: apps/v1
+5 -4
View File
@@ -57,11 +57,12 @@ export default function AnalyticsPage() {
const headers = getAuthHeaders()
// Fetch real data from multiple endpoints
const apiUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"
const [nodesRes, chainsRes, keysRes, webhooksRes] = await Promise.all([
fetch("http://localhost:8000/v1/nodes/", { headers }).catch(() => null),
fetch("http://localhost:8000/v1/chains", { headers }).catch(() => null),
fetch("http://localhost:8000/v1/auth/keys", { headers }).catch(() => null),
fetch("http://localhost:8000/v1/webhooks", { headers }).catch(() => null),
fetch(`${apiUrl}/v1/nodes/`, { headers }).catch(() => null),
fetch(`${apiUrl}/v1/chains`, { headers }).catch(() => null),
fetch(`${apiUrl}/v1/auth/keys`, { headers }).catch(() => null),
fetch(`${apiUrl}/v1/webhooks`, { headers }).catch(() => null),
])
const nodes = nodesRes?.ok ? await nodesRes.json() : []
+3 -2
View File
@@ -35,9 +35,10 @@ export default function OrganizationPage() {
try {
const headers = getAuthHeaders()
const apiUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"
const [teamRes, keysRes] = await Promise.all([
fetch("http://localhost:8000/v1/team", { headers }).catch(() => null),
fetch("http://localhost:8000/v1/auth/keys", { headers }).catch(() => null),
fetch(`${apiUrl}/v1/team`, { headers }).catch(() => null),
fetch(`${apiUrl}/v1/auth/keys`, { headers }).catch(() => null),
])
const teamData = teamRes?.ok ? await teamRes.json() : { members: [], total: 0 }
+6 -5
View File
@@ -66,12 +66,13 @@ export default function DashboardPage() {
const headers = getAuthHeaders()
// Fetch multiple endpoints in parallel
const apiUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"
const [teamRes, keysRes, webhooksRes, chainsRes, nodesRes] = await Promise.all([
fetch("http://localhost:8000/v1/team", { headers }).catch(() => null),
fetch("http://localhost:8000/v1/auth/keys", { headers }).catch(() => null),
fetch("http://localhost:8000/v1/webhooks", { headers }).catch(() => null),
fetch("http://localhost:8000/v1/chains", { headers }).catch(() => null),
fetch("http://localhost:8000/v1/nodes/", { headers }).catch(() => null),
fetch(`${apiUrl}/v1/team`, { headers }).catch(() => null),
fetch(`${apiUrl}/v1/auth/keys`, { headers }).catch(() => null),
fetch(`${apiUrl}/v1/webhooks`, { headers }).catch(() => null),
fetch(`${apiUrl}/v1/chains`, { headers }).catch(() => null),
fetch(`${apiUrl}/v1/nodes/`, { headers }).catch(() => null),
])
// Parse responses
+2 -2
View File
@@ -64,7 +64,7 @@ export default function PrivacyPage() {
<p className="mt-2">We collect information you voluntarily provide when you:</p>
<ul className="mt-2 ml-4 list-disc space-y-1">
<li>Create an account (email address, name, password)</li>
<li>Subscribe to a paid plan (billing address, payment information via our payment processor Stripe)</li>
<li>Subscribe to a paid plan (billing address, payment information via our payment processor)</li>
<li>Contact us via email or our contact form (name, email, message content)</li>
<li>Participate in surveys or promotions</li>
</ul>
@@ -109,7 +109,7 @@ export default function PrivacyPage() {
<ul className="mt-2 ml-4 list-disc space-y-1">
<li>
<strong className="text-foreground">Service providers:</strong> Third-party vendors who assist
in operating the Service (e.g., Stripe for payments, cloud infrastructure
in operating the Service (e.g., Square for payments, cloud infrastructure
providers for hosting, analytics providers). These vendors are
contractually obligated to protect your information.
</li>
+1 -1
View File
@@ -133,7 +133,7 @@ export default function TermsPage() {
<h3 className="mt-4 text-base font-semibold text-foreground">5.2 Payment</h3>
<p className="mt-2">
Paid plans are billed in advance on a monthly or annual basis. Payment is
processed through Stripe. By providing payment information, you authorize
processed through Square via Hanzo Commerce. By providing payment information, you authorize
us to charge the applicable fees to your payment method. All fees are
stated in US Dollars and are non-refundable except as expressly stated
herein or required by law.
+30
View File
@@ -144,6 +144,36 @@ export const transfers = {
api(`/transfers/${chain}/send`, { method: "POST", body: JSON.stringify(data) }),
}
// Billing
export const billing = {
getUsage: () => api("/billing/usage"),
getUsageSummary: (year?: number, month?: number) => {
const params = year && month ? `?year=${year}&month=${month}` : ""
return api(`/billing/usage/summary${params}`)
},
getSubscription: () => api("/billing/subscription"),
upgrade: (data: { tier: string; hanzo_subscription_id?: string }) =>
api("/billing/subscription/upgrade", { method: "POST", body: JSON.stringify(data) }),
downgrade: (data: { tier: string }) =>
api("/billing/subscription/downgrade", { method: "POST", body: JSON.stringify(data) }),
getInvoices: (limit?: number) => api(`/billing/invoices${limit ? `?limit=${limit}` : ""}`),
getTiers: () => api("/billing/tiers"),
getTier: (name: string) => api(`/billing/tiers/${name}`),
checkLimits: () => api("/billing/limits"),
createCheckout: (data: { tier: string; nonce?: string; source_id?: string; success_url?: string; cancel_url?: string }) =>
api("/billing/checkout", { method: "POST", body: JSON.stringify(data) }),
captureCheckout: (orderId: string) =>
api(`/billing/checkout/capture/${orderId}`, { method: "POST" }),
syncUsage: () => api("/billing/sync", { method: "POST" }),
getSyncStatus: () => api("/billing/sync/status"),
// Unified IAM + Commerce
getAccount: () => api("/billing/account"),
getAccountSubscriptions: () => api("/billing/account/subscriptions"),
getAccountInvoices: () => api("/billing/account/invoices"),
getPaymentMethods: () => api("/billing/account/payment-methods"),
syncAccount: () => api("/billing/account/sync", { method: "POST" }),
}
// Team
export const team = {
list: () => api<{ members: TeamMember[]; total: number }>("/team"),