Move every caller and route to /v1

Commerce is called at /v1, the indexer at /v1/indexer, and the operator
serves /v1/clusters. The console's unused fleet helpers called a /api
base nothing serves; they are gone. The legacy Quart env files named an
app that no longer exists.

Claude-Session: https://claude.ai/code/session_011i2AG9nwpiKUCVxAdDzYuU
This commit is contained in:
2026-09-29 18:34:58 -07:00
parent d0507e0a03
commit 4c917f849b
20 changed files with 134 additions and 195 deletions
-5
View File
@@ -1,5 +0,0 @@
# Legacy Bootnode - Development Configuration
LEGACY_API_TOKEN=bn_legacy_dev_token_12345
LEGACY_ADMIN_EMAIL=[email protected]
LEGACY_ADMIN_PASSWORD=devpassword123
MONGO_URL=mongodb://localhost:27017/bootnode
-12
View File
@@ -1,12 +0,0 @@
# Legacy Bootnode - Secured Configuration
# For the legacy Quart application
# Authentication (Set secure values!)
LEGACY_API_TOKEN=bn_legacy_secure_token_replace_with_random_32_chars
LEGACY_ADMIN_EMAIL=[email protected]
LEGACY_ADMIN_PASSWORD=your-strong-admin-password-here
# MongoDB
MONGO_URL=mongodb://localhost:27017/bootnode
# WARNING: This is the legacy app - migrate to /api/bootnode for modern features
+20 -12
View File
@@ -104,6 +104,14 @@ bootnode/
└── docs/ # Documentation
```
### Paths
Every path bootnode serves or calls is `/v1/...`; auth is `/v1/iam/*`. The API
mounts at `settings.api_prefix = "/v1"`, the operator at `/v1/clusters/...`,
commerce is called at `/v1/...` and the indexer at `/v1/indexer/...`. The one
`/api/` address is the Kubernetes API. `console/lib/no-api-prefix.test.ts`
fails on any other.
### API Design
#### RPC API
@@ -1076,13 +1084,13 @@ import { BrandLogo } from "@/components/brand-logo"
Billing goes through Hanzo Commerce (Go service, `commerce.hanzo.ai`) which handles payment processing via Square. Bootnode is a client of Commerce — it never talks to payment processors directly.
```
User → Dashboard → POST /v1/billing/checkout → Bootnode API → Commerce /api/v1/checkout/authorize → Square
→ GET /v1/billing/account → Bootnode API → Commerce /api/v1/user → user+subscription
User → Dashboard → POST /v1/billing/checkout → Bootnode API → Commerce /v1/checkout/authorize → Square
→ GET /v1/billing/account → Bootnode API → Commerce /v1/user → user+subscription
Commerce Webhooks → POST /v1/billing/webhooks/commerce
→ verify HMAC signature → update local DB → invalidate Redis cache
Usage: API request → Redis CU counter → hourly sync → Commerce /api/v1/subscriptions/:id/usage
Usage: API request → Redis CU counter → hourly sync → Commerce /v1/subscriptions/:id/usage
```
### Commerce Service
@@ -1105,15 +1113,15 @@ Usage: API request → Redis CU counter → hourly sync → Commerce /api/v1/sub
### Commerce API Endpoints (used by Bootnode)
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/api/v1/checkout/authorize` | POST | Authorize payment via Square (nonce) |
| `/api/v1/checkout/capture/:orderid` | POST | Capture authorized payment |
| `/api/v1/checkout/charge` | POST | Single-step charge |
| `/api/v1/subscribe` | POST | Create subscription |
| `/api/v1/subscribe/:id` | GET | Get subscription |
| `/api/v1/subscribe/:id` | PATCH | Update subscription |
| `/api/v1/subscribe/:id` | DELETE | Cancel subscription |
| `/api/v1/user` | GET | Get user + subscription data |
| `/api/v1/order` | GET | List orders |
| `/v1/checkout/authorize` | POST | Authorize payment via Square (nonce) |
| `/v1/checkout/capture/:orderid` | POST | Capture authorized payment |
| `/v1/checkout/charge` | POST | Single-step charge |
| `/v1/subscribe` | POST | Create subscription |
| `/v1/subscribe/:id` | GET | Get subscription |
| `/v1/subscribe/:id` | PATCH | Update subscription |
| `/v1/subscribe/:id` | DELETE | Cancel subscription |
| `/v1/user` | GET | Get user + subscription data |
| `/v1/order` | GET | List orders |
| `/health` | GET | Health check |
### Bootnode Billing Key Files
+1 -1
View File
@@ -1,3 +1,3 @@
"""Bootnode - Blockchain Development Platform."""
__version__ = "1.0.4"
__version__ = "1.0.5"
+19 -19
View File
@@ -113,7 +113,7 @@ class HanzoCommerceClient:
"""Create customer in Hanzo Commerce. Returns customer_id."""
data = await self._request(
"POST",
"/api/v1/user",
"/v1/user",
json={
"email": email,
"name": name,
@@ -136,7 +136,7 @@ class HanzoCommerceClient:
async def get_customer(self, customer_id: str) -> Customer:
"""Get customer by ID."""
data = await self._request("GET", f"/api/v1/user/{customer_id}")
data = await self._request("GET", f"/v1/user/{customer_id}")
return Customer(
id=data["id"],
email=data["email"],
@@ -155,7 +155,7 @@ class HanzoCommerceClient:
try:
data = await self._request(
"GET",
"/api/v1/user",
"/v1/user",
params={"email": email},
)
customers = data.get("data", [])
@@ -192,7 +192,7 @@ class HanzoCommerceClient:
data = await self._request(
"PATCH",
f"/api/v1/user/{customer_id}",
f"/v1/user/{customer_id}",
json=update_data,
)
return Customer(
@@ -250,7 +250,7 @@ class HanzoCommerceClient:
data = await self._request(
"POST",
"/api/v1/checkout/authorize",
"/v1/checkout/authorize",
json=payload,
)
@@ -273,7 +273,7 @@ class HanzoCommerceClient:
"""
data = await self._request(
"POST",
f"/api/v1/checkout/capture/{order_id}",
f"/v1/checkout/capture/{order_id}",
)
logger.info("Checkout captured", order_id=order_id)
@@ -311,7 +311,7 @@ class HanzoCommerceClient:
return await self._request(
"POST",
"/api/v1/checkout/charge",
"/v1/checkout/charge",
json=payload,
)
@@ -335,7 +335,7 @@ class HanzoCommerceClient:
"""
data = await self._request(
"GET",
f"/api/v1/user/{customer_id}/orders",
f"/v1/user/{customer_id}/orders",
params={"limit": limit},
)
return data.get("data", [])
@@ -356,7 +356,7 @@ class HanzoCommerceClient:
"""
data = await self._request(
"POST",
"/api/v1/subscribe",
"/v1/subscribe",
json={
"customer_id": customer_id,
"plan_slug": plan_slug,
@@ -390,7 +390,7 @@ class HanzoCommerceClient:
async def get_subscription(self, subscription_id: str) -> Subscription:
"""Get subscription by ID."""
data = await self._request("GET", f"/api/v1/subscribe/{subscription_id}")
data = await self._request("GET", f"/v1/subscribe/{subscription_id}")
return Subscription(
id=data["id"],
customer_id=data["customer_id"],
@@ -414,7 +414,7 @@ class HanzoCommerceClient:
"""Get all subscriptions for a customer (via user orders endpoint)."""
data = await self._request(
"GET",
f"/api/v1/user/{customer_id}/orders",
f"/v1/user/{customer_id}/orders",
params={"type": "subscription"},
)
return [
@@ -440,7 +440,7 @@ class HanzoCommerceClient:
"""Upgrade or downgrade subscription."""
data = await self._request(
"PATCH",
f"/api/v1/subscribe/{subscription_id}",
f"/v1/subscribe/{subscription_id}",
json={"plan_slug": plan_slug},
)
@@ -474,7 +474,7 @@ class HanzoCommerceClient:
"""
data = await self._request(
"DELETE",
f"/api/v1/subscribe/{subscription_id}",
f"/v1/subscribe/{subscription_id}",
json={"immediately": immediately},
)
@@ -502,7 +502,7 @@ class HanzoCommerceClient:
"""
await self._request(
"POST",
f"/api/v1/subscribe/{subscription_id}/usage",
f"/v1/subscribe/{subscription_id}/usage",
json={
"quantity": compute_units,
"timestamp": timestamp.isoformat(),
@@ -532,7 +532,7 @@ class HanzoCommerceClient:
return await self._request(
"GET",
f"/api/v1/subscribe/{subscription_id}/usage",
f"/v1/subscribe/{subscription_id}/usage",
params=params or None,
)
@@ -548,7 +548,7 @@ class HanzoCommerceClient:
"""Get customer invoices (mapped from Commerce orders)."""
data = await self._request(
"GET",
f"/api/v1/user/{customer_id}/orders",
f"/v1/user/{customer_id}/orders",
params={"limit": limit},
)
return [
@@ -584,7 +584,7 @@ class HanzoCommerceClient:
async def get_invoice(self, invoice_id: str) -> Invoice:
"""Get specific invoice (mapped from Commerce order)."""
inv = await self._request("GET", f"/api/v1/order/{invoice_id}")
inv = await self._request("GET", f"/v1/order/{invoice_id}")
return Invoice(
id=inv["id"],
customer_id=inv["customer_id"],
@@ -616,7 +616,7 @@ class HanzoCommerceClient:
try:
inv = await self._request(
"GET",
f"/api/v1/user/{customer_id}/orders",
f"/v1/user/{customer_id}/orders",
params={"status": "draft", "limit": 1},
)
return Invoice(
@@ -652,7 +652,7 @@ class HanzoCommerceClient:
"""Get payment methods for a customer (Square cards on file)."""
data = await self._request(
"GET",
f"/api/v1/user/{customer_id}/paymentmethods",
f"/v1/user/{customer_id}/paymentmethods",
)
return data.get("data", [])
+8 -8
View File
@@ -120,7 +120,7 @@ class UnifiedBillingClient:
async with httpx.AsyncClient(timeout=self._timeout) as client:
try:
response = await client.get(
f"{self.commerce_url}/api/v1/user",
f"{self.commerce_url}/v1/user",
params={"hanzo_id": hanzo_id},
headers=self._commerce_headers(),
)
@@ -144,7 +144,7 @@ class UnifiedBillingClient:
"""Create Commerce customer linked to IAM user."""
async with httpx.AsyncClient(timeout=self._timeout) as client:
response = await client.post(
f"{self.commerce_url}/api/v1/user",
f"{self.commerce_url}/v1/user",
json={
"email": iam_user.email,
"name": iam_user.name,
@@ -186,7 +186,7 @@ class UnifiedBillingClient:
async with httpx.AsyncClient(timeout=self._timeout) as client:
response = await client.get(
f"{self.commerce_url}/api/v1/user/{unified_user.commerce_customer_id}/orders",
f"{self.commerce_url}/v1/user/{unified_user.commerce_customer_id}/orders",
headers=self._commerce_headers(),
)
@@ -204,7 +204,7 @@ class UnifiedBillingClient:
async with httpx.AsyncClient(timeout=self._timeout) as client:
response = await client.get(
f"{self.commerce_url}/api/v1/user/{unified_user.commerce_customer_id}/orders",
f"{self.commerce_url}/v1/user/{unified_user.commerce_customer_id}/orders",
params={"type": "invoice"},
headers=self._commerce_headers(),
)
@@ -223,7 +223,7 @@ class UnifiedBillingClient:
async with httpx.AsyncClient(timeout=self._timeout) as client:
response = await client.get(
f"{self.commerce_url}/api/v1/user/{unified_user.commerce_customer_id}/paymentmethods",
f"{self.commerce_url}/v1/user/{unified_user.commerce_customer_id}/paymentmethods",
headers=self._commerce_headers(),
)
@@ -247,7 +247,7 @@ class UnifiedBillingClient:
async with httpx.AsyncClient(timeout=self._timeout) as client:
response = await client.post(
f"{self.commerce_url}/api/v1/subscribe",
f"{self.commerce_url}/v1/subscribe",
json={
"customer_id": unified_user.commerce_customer_id,
"plan_id": plan_id,
@@ -280,7 +280,7 @@ class UnifiedBillingClient:
"""Report metered usage for PAYG billing."""
async with httpx.AsyncClient(timeout=self._timeout) as client:
response = await client.post(
f"{self.commerce_url}/api/v1/subscribe/{subscription_id}/usage",
f"{self.commerce_url}/v1/subscribe/{subscription_id}/usage",
json={
"quantity": compute_units,
"unit": "compute_units",
@@ -319,7 +319,7 @@ class UnifiedBillingClient:
async with httpx.AsyncClient(timeout=self._timeout) as client:
response = await client.patch(
f"{self.commerce_url}/api/v1/user/{unified_user.commerce_customer_id}",
f"{self.commerce_url}/v1/user/{unified_user.commerce_customer_id}",
json={
"email": iam_user.email,
"name": iam_user.name,
+13 -13
View File
@@ -79,7 +79,7 @@ class UnifiedQueryClient:
async with httpx.AsyncClient(timeout=self._timeout) as client:
# Query Lux Indexer
response = await client.get(
f"{self.indexer_url}/api/v2/{chain}/{network}/blocks/{block_number}"
f"{self.indexer_url}/v1/indexer/{chain}/{network}/blocks/{block_number}"
)
if response.status_code == 200:
@@ -118,7 +118,7 @@ class UnifiedQueryClient:
async with httpx.AsyncClient(timeout=self._timeout) as client:
response = await client.get(
f"{self.indexer_url}/api/v2/{chain}/{network}/blocks",
f"{self.indexer_url}/v1/indexer/{chain}/{network}/blocks",
params=params,
)
@@ -158,7 +158,7 @@ class UnifiedQueryClient:
async with httpx.AsyncClient(timeout=self._timeout) as client:
response = await client.get(
f"{self.indexer_url}/api/v2/{chain}/{network}/transactions/{tx_hash}"
f"{self.indexer_url}/v1/indexer/{chain}/{network}/transactions/{tx_hash}"
)
if response.status_code == 200:
@@ -199,11 +199,11 @@ class UnifiedQueryClient:
# Determine endpoint based on filters
if address:
endpoint = f"{self.indexer_url}/api/v2/{chain}/{network}/addresses/{address}/transactions"
endpoint = f"{self.indexer_url}/v1/indexer/{chain}/{network}/addresses/{address}/transactions"
elif block_number:
endpoint = f"{self.indexer_url}/api/v2/{chain}/{network}/blocks/{block_number}/transactions"
endpoint = f"{self.indexer_url}/v1/indexer/{chain}/{network}/blocks/{block_number}/transactions"
else:
endpoint = f"{self.indexer_url}/api/v2/{chain}/{network}/transactions"
endpoint = f"{self.indexer_url}/v1/indexer/{chain}/{network}/transactions"
async with httpx.AsyncClient(timeout=self._timeout) as client:
response = await client.get(endpoint, params=params)
@@ -244,7 +244,7 @@ class UnifiedQueryClient:
async with httpx.AsyncClient(timeout=self._timeout) as client:
response = await client.get(
f"{self.indexer_url}/api/v2/{chain}/{network}/addresses/{address}"
f"{self.indexer_url}/v1/indexer/{chain}/{network}/addresses/{address}"
)
if response.status_code == 200:
@@ -279,7 +279,7 @@ class UnifiedQueryClient:
async with httpx.AsyncClient(timeout=self._timeout) as client:
response = await client.get(
f"{self.indexer_url}/api/v2/{chain}/{network}/addresses/{address}/tokens",
f"{self.indexer_url}/v1/indexer/{chain}/{network}/addresses/{address}/tokens",
params={"limit": limit},
)
@@ -318,7 +318,7 @@ class UnifiedQueryClient:
async with httpx.AsyncClient(timeout=self._timeout) as client:
response = await client.get(
f"{self.indexer_url}/api/v2/{chain}/{network}/tokens/{address}"
f"{self.indexer_url}/v1/indexer/{chain}/{network}/tokens/{address}"
)
if response.status_code == 200:
@@ -360,7 +360,7 @@ class UnifiedQueryClient:
async with httpx.AsyncClient(timeout=self._timeout) as client:
response = await client.get(
f"{self.indexer_url}/api/v2/{chain}/{network}/tokens",
f"{self.indexer_url}/v1/indexer/{chain}/{network}/tokens",
params=params,
)
@@ -400,7 +400,7 @@ class UnifiedQueryClient:
async with httpx.AsyncClient(timeout=self._timeout) as client:
response = await client.get(
f"{self.indexer_url}/api/v2/{chain}/{network}/smart-contracts/{address}"
f"{self.indexer_url}/v1/indexer/{chain}/{network}/smart-contracts/{address}"
)
if response.status_code == 200:
@@ -452,7 +452,7 @@ class UnifiedQueryClient:
async with httpx.AsyncClient(timeout=self._timeout) as client:
response = await client.get(
f"{self.indexer_url}/api/v2/{chain}/{network}/logs",
f"{self.indexer_url}/v1/indexer/{chain}/{network}/logs",
params=params,
)
@@ -527,7 +527,7 @@ class UnifiedQueryClient:
async with httpx.AsyncClient(timeout=self._timeout) as client:
response = await client.get(
f"{self.indexer_url}/api/v2/{chain}/{network}/stats"
f"{self.indexer_url}/v1/indexer/{chain}/{network}/stats"
)
if response.status_code == 200:
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "bootnode"
version = "1.0.4"
version = "1.0.5"
description = "Blockchain Development Platform - Multi-chain RPC, Token API, NFT API, Smart Wallets, and more"
readme = "README.md"
license = { text = "BSD-3-Clause" }
+4 -4
View File
@@ -268,7 +268,7 @@ class TestCommerceClient:
assert result["checkout_url"] is not None
client._request.assert_called_once_with(
"POST",
"/api/v1/checkout/authorize",
"/v1/checkout/authorize",
json=pytest.approx(client._request.call_args[1]["json"], abs=0) if False else client._request.call_args[1].get("json", client._request.call_args[0][2] if len(client._request.call_args[0]) > 2 else None),
)
@@ -286,7 +286,7 @@ class TestCommerceClient:
result = await client.capture_checkout("order_123")
assert result["status"] == "completed"
client._request.assert_called_once_with("POST", "/api/v1/checkout/capture/order_123")
client._request.assert_called_once_with("POST", "/v1/checkout/capture/order_123")
@patch("bootnode.core.billing.commerce.get_settings")
@pytest.mark.asyncio
@@ -621,7 +621,7 @@ class TestCommerceClientErrors:
MockAsyncClient.return_value = mock_client
with pytest.raises(CommerceError) as exc_info:
await client._request("GET", "/api/v1/test")
await client._request("GET", "/v1/test")
assert exc_info.value.status_code == 400
assert "bad request" in exc_info.value.message
@@ -648,7 +648,7 @@ class TestCommerceClientErrors:
MockAsyncClient.return_value = mock_client
with pytest.raises(CommerceError) as exc_info:
await client._request("GET", "/api/v1/test")
await client._request("GET", "/v1/test")
assert exc_info.value.status_code == 503
+4 -4
View File
@@ -451,7 +451,7 @@ X-RateLimit-Reset: 1706400000`}</code>
-H "Content-Type: application/json" \\
-H "X-API-Key: YOUR_API_KEY" \\
-d '{
"url": "https://myapp.com/api/webhooks/bootnode",
"url": "https://myapp.com/webhooks/bootnode",
"chain": "ethereum",
"event_type": "address_activity",
"config": {
@@ -460,7 +460,7 @@ X-RateLimit-Reset: 1706400000`}</code>
}'`}
response={`{
"id": "wh_a1b2c3d4e5f6",
"url": "https://myapp.com/api/webhooks/bootnode",
"url": "https://myapp.com/webhooks/bootnode",
"chain": "ethereum",
"event_type": "address_activity",
"config": {
@@ -486,7 +486,7 @@ X-RateLimit-Reset: 1706400000`}</code>
"webhooks": [
{
"id": "wh_a1b2c3d4e5f6",
"url": "https://myapp.com/api/webhooks/bootnode",
"url": "https://myapp.com/webhooks/bootnode",
"chain": "ethereum",
"event_type": "address_activity",
"status": "active",
@@ -515,7 +515,7 @@ X-RateLimit-Reset: 1706400000`}</code>
}'`}
response={`{
"id": "wh_a1b2c3d4e5f6",
"url": "https://myapp.com/api/webhooks/bootnode",
"url": "https://myapp.com/webhooks/bootnode",
"chain": "ethereum",
"event_type": "address_activity",
"status": "paused",
+2 -2
View File
@@ -147,7 +147,7 @@ async function monitorWallet(address: string) {
"X-API-Key": process.env.BOOTNODE_API_KEY!,
},
body: JSON.stringify({
url: "https://myapp.com/api/webhooks/wallet-activity",
url: "https://myapp.com/webhooks/wallet-activity",
chain: "ethereum",
event_type: "address_activity",
config: {
@@ -163,7 +163,7 @@ async function monitorWallet(address: string) {
}
// 2. Handle incoming events (Next.js API route)
// app/api/webhooks/wallet-activity/route.ts
// app/webhooks/wallet-activity/route.ts
import { NextRequest, NextResponse } from "next/server";
import crypto from "crypto";
+1 -1
View File
@@ -86,7 +86,7 @@ console.log("Smart wallet:", wallet.address);
// Webhooks
const webhook = await client.webhooks.create({
url: "https://myapp.com/api/webhooks",
url: "https://myapp.com/webhooks",
chain: "ethereum",
eventType: "address_activity",
config: { addresses: ["0xd8dA...6045"] },
+1 -1
View File
@@ -276,7 +276,7 @@ export async function sendGaslessTransfer({
sponsored transaction:
</p>
<pre className="overflow-x-auto rounded-lg bg-zinc-950 p-4 text-sm text-zinc-100">
<code>{`// app/api/wallet/create/route.ts
<code>{`// app/wallet/create/route.ts
import { NextRequest, NextResponse } from "next/server";
const BOOTNODE_API_KEY = process.env.BOOTNODE_API_KEY!;
@@ -74,7 +74,7 @@ export default function WebhooksQuickstartPage() {
-H "Content-Type: application/json" \\
-H "X-API-Key: YOUR_API_KEY" \\
-d '{
"url": "https://myapp.com/api/webhooks/bootnode",
"url": "https://myapp.com/webhooks/bootnode",
"chain": "ethereum",
"event_type": "address_activity",
"config": {
@@ -89,7 +89,7 @@ export default function WebhooksQuickstartPage() {
<pre className="overflow-x-auto rounded-lg bg-zinc-950 p-4 text-sm text-zinc-100">
<code>{`{
"id": "wh_a1b2c3d4e5f6",
"url": "https://myapp.com/api/webhooks/bootnode",
"url": "https://myapp.com/webhooks/bootnode",
"chain": "ethereum",
"event_type": "address_activity",
"config": {
@@ -135,7 +135,7 @@ const SIGNING_SECRET = process.env.WEBHOOK_SIGNING_SECRET!;
// IMPORTANT: Use raw body for signature verification
app.post(
"/api/webhooks/bootnode",
"/webhooks/bootnode",
express.raw({ type: "application/json" }),
(req, res) => {
const signature = req.headers["x-bootnode-signature"] as string;
+2 -2
View File
@@ -333,7 +333,7 @@ export default function MPCProductPage() {
<CardContent className="p-0">
<pre className="overflow-x-auto rounded-lg bg-zinc-950 p-6 text-sm text-zinc-100">
<code>{`# Create a distributed wallet
curl -X POST https://mpc.yourorg.com/api/v1/wallets \\
curl -X POST https://mpc.yourorg.com/v1/wallets \\
-H "Authorization: Bearer \$TOKEN" \\
-d '{"name": "treasury", "chains": ["ETH", "BTC", "SOL"]}'
@@ -350,7 +350,7 @@ curl -X POST https://mpc.yourorg.com/api/v1/wallets \\
}
# Sign an Ethereum transaction
curl -X POST https://mpc.yourorg.com/api/v1/sign \\
curl -X POST https://mpc.yourorg.com/v1/sign \\
-H "Authorization: Bearer \$TOKEN" \\
-d '{
"wallet_id": "w_8f3a...",
-100
View File
@@ -1,4 +1,3 @@
import { getApiUrl } from "@/lib/api"
// DigitalOcean Kubernetes regions and Lux network configuration
// Used by the fleet management UI for cluster provisioning and monitoring.
@@ -57,102 +56,3 @@ export const NETWORK_CONFIGS: Record<LuxNetwork, LuxNetworkConfig> = {
};
export const NETWORK_NAMES: LuxNetwork[] = ["mainnet", "testnet", "devnet"] as const;
// ---------------------------------------------------------------------------
// API Client Helpers
// ---------------------------------------------------------------------------
// One resolver for every caller (lib/api.ts), host-derived at runtime.
const API_BASE = getApiUrl();
function authHeaders(): HeadersInit {
const headers: HeadersInit = { "Content-Type": "application/json" };
if (typeof window !== "undefined") {
const token = localStorage.getItem("token");
if (token) {
headers["Authorization"] = `Bearer ${token}`;
}
}
return headers;
}
async function request<T = unknown>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${API_BASE}${path}`, {
...init,
headers: { ...authHeaders(), ...init?.headers },
});
if (!res.ok) {
const body = await res.text().catch(() => "");
throw new Error(`${res.status} ${res.statusText}: ${body}`);
}
// 204 No Content
if (res.status === 204) return undefined as T;
return res.json() as Promise<T>;
}
/** List all fleets, optionally filtered by cluster. */
export async function fetchFleets(clusterId?: string) {
const qs = clusterId ? `?clusterId=${encodeURIComponent(clusterId)}` : "";
return request(`/api/fleets${qs}`);
}
/** Get a single fleet by cluster + network. */
export async function fetchFleet(clusterId: string, network: LuxNetwork) {
return request(`/api/fleets/${encodeURIComponent(clusterId)}/${network}`);
}
/** Create a new fleet. */
export async function createFleet(params: Record<string, unknown>) {
return request("/api/fleets", {
method: "POST",
body: JSON.stringify(params),
});
}
/** Update an existing fleet. */
export async function updateFleet(
clusterId: string,
network: LuxNetwork,
params: Record<string, unknown>,
) {
return request(`/api/fleets/${encodeURIComponent(clusterId)}/${network}`, {
method: "PUT",
body: JSON.stringify(params),
});
}
/** Delete a fleet. */
export async function deleteFleet(clusterId: string, network: LuxNetwork) {
return request(`/api/fleets/${encodeURIComponent(clusterId)}/${network}`, {
method: "DELETE",
});
}
/** Scale a fleet to the given replica count. */
export async function scaleFleet(
clusterId: string,
network: LuxNetwork,
replicas: number,
) {
return request(`/api/fleets/${encodeURIComponent(clusterId)}/${network}/scale`, {
method: "POST",
body: JSON.stringify({ replicas }),
});
}
/** Restart all nodes in a fleet. */
export async function restartFleet(clusterId: string, network: LuxNetwork) {
return request(`/api/fleets/${encodeURIComponent(clusterId)}/${network}/restart`, {
method: "POST",
});
}
/** Fetch aggregate stats across all fleets. */
export async function fetchStats() {
return request("/api/stats");
}
/** Fetch available Lux networks. */
export async function fetchNetworks() {
return request("/api/networks");
}
+50
View File
@@ -0,0 +1,50 @@
// Every path bootnode serves or calls is `/v1/...`; auth is `/v1/iam/*`.
//
// Run: `pnpm test`.
//
// Walks the first-party source of every component in this repo — the Python
// API, the console, the operator, the fleet service — for an `/api/` path
// written as a literal or appended to a base (`"/api/…"`, `` `${base}/api/…` ``,
// `f"{url}/api/…"`). The one address allowed is the Kubernetes API
// (`/api/v1/namespaces/…`), which is not ours. Tests are skipped: the fleet
// suite requests `/api/…` on purpose, to prove it 404s.
import { strict as assert } from "node:assert"
import { readdirSync, readFileSync, statSync } from "node:fs"
import { join, relative } from "node:path"
import { describe, it } from "node:test"
import { fileURLToPath } from "node:url"
const ROOT = join(fileURLToPath(new URL(".", import.meta.url)), "..", "..")
const DIRS = ["api/bootnode", "api/handlers", "api/store", "api/middleware", "console/app", "console/components", "console/lib", "operator/src", "fleet", "cmd"]
const SOURCE = /\.(py|go|rs|ts|tsx|js|mjs)$/
const TEST = /(\.test\.tsx?|_test\.go|\/test_[^/]*\.py)$/
const PATH = /['"`(}]\/api\//
const KUBERNETES = /\/api\/v1\/namespaces/
function walk(dir: string, out: string[] = []): string[] {
for (const entry of readdirSync(dir)) {
if (entry === "node_modules" || entry === "__pycache__") continue
const full = join(dir, entry)
if (statSync(full).isDirectory()) walk(full, out)
else out.push(full)
}
return out
}
describe("the /v1 prefix", () => {
it("no first-party source names an /api/ path", () => {
const hits: string[] = []
for (const dir of DIRS) {
for (const file of walk(join(ROOT, dir))) {
if (!SOURCE.test(file) || TEST.test(file)) continue
readFileSync(file, "utf8")
.split("\n")
.forEach((line, i) => {
if (PATH.test(line) && !KUBERNETES.test(line)) hits.push(`${relative(ROOT, file)}:${i + 1}: ${line.trim()}`)
})
}
}
assert.deepEqual(hits, [])
})
})
+1 -1
View File
@@ -321,7 +321,7 @@ services:
redis:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/api/health"]
test: ["CMD", "curl", "-f", "http://localhost:8000/v1/iam/health"]
interval: 30s
timeout: 10s
retries: 3
+2 -4
View File
@@ -62,10 +62,8 @@ spec:
limits:
cpu: "1000m"
memory: "512Mi"
# Probe `/`, not `/api/health`. The runtime is a static file server:
# `/` is the one path guaranteed to exist and it proves the bundle is
# actually being served. `/api/health` also broke the standing rule
# that public paths are /v1/*, never /api/*.
# Probe `/`. The runtime is a static file server: `/` is the one path
# guaranteed to exist and it proves the bundle is actually being served.
livenessProbe:
httpGet:
path: /
+2 -2
View File
@@ -28,8 +28,8 @@ pub fn router(state: Arc<ApiState>) -> Router {
Router::new()
.route("/healthz", get(healthz))
.route("/readyz", get(readyz))
.route("/api/v1/clusters/:namespace/:name/nodes", get(list_nodes))
.route("/api/v1/clusters/:namespace/:name/restart", post(restart_cluster))
.route("/v1/clusters/:namespace/:name/nodes", get(list_nodes))
.route("/v1/clusters/:namespace/:name/restart", post(restart_cluster))
.with_state(state)
}