mirror of
https://github.com/hanzoai/bootnode.git
synced 2026-09-30 20:46:32 +00:00
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:
+2
-2
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() : []
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"),
|
||||
|
||||
Reference in New Issue
Block a user