diff --git a/.env.example b/.env.example
index 4afb91b2..777fd7a6 100644
--- a/.env.example
+++ b/.env.example
@@ -1,13 +1,9 @@
# Port for the proxy server (optional, defaults to 8080)
PORT=8080
-# Load-balancing strategy: least-requests | round-robin | session | weighted | weighted-round-robin
-# - least-requests: Route to account with fewest requests (default)
-# - round-robin: Distribute requests evenly across all accounts
+# Load-balancing strategy: session
# - session: Maintain 5-hour sessions per account
-# - weighted: Route based on tier-adjusted request count (respects 1x, 5x, 20x tiers)
-# - weighted-round-robin: Round-robin that gives more slots to higher tier accounts
-LB_STRATEGY=least-requests
+LB_STRATEGY=session
# Log level: DEBUG | INFO | WARN | ERROR (optional, defaults to INFO)
LOG_LEVEL=INFO
@@ -17,5 +13,10 @@ LOG_LEVEL=INFO
# - json: Structured JSON logs for log aggregators
LOG_FORMAT=pretty
+# Authentication settings
+# Enable/disable authentication for dashboard and API endpoints
+# Set to true to require login, false to disable auth (default: false)
+AUTH_ENABLED=false
+
# Example of how to use the proxy with your application:
# ANTHROPIC_BASE_URL=http://localhost:8080
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index 8020a75f..ff28d626 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,7 @@
node_modules/
.env
.env.local
+.gitattributes
ccflare.db
ccflare.db-wal
ccflare.db-shm
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 00000000..640147b1
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,30 @@
+# Use the official Bun image
+FROM oven/bun:latest
+
+# Set working directory
+WORKDIR /app
+
+# Copy package.json and bun.lockb
+COPY package.json bun.lock* ./
+
+# Copy the rest of the application
+COPY . .
+
+# Install dependencies
+RUN bun install
+
+# Build the project
+RUN bun run build
+
+# Environment variables with default values
+ENV PORT=8080
+ENV AUTH_ENABLED=false
+ENV LOG_LEVEL=INFO
+ENV LOG_FORMAT=pretty
+ENV LB_STRATEGY=session
+
+# Expose port 8080
+EXPOSE 8080
+
+# Run the server (not the TUI which requires interactive mode)
+CMD ["bun", "run", "server"]
diff --git a/README.md b/README.md
index ae8cabad..c3e78445 100644
--- a/README.md
+++ b/README.md
@@ -20,6 +20,23 @@ https://github.com/user-attachments/assets/c859872f-ca5e-4f8b-b6a0-7cc7461fe62a
## Quick Start
+### With Docker (Recommended)
+
+```bash
+# Clone the repository
+git clone https://github.com/snipeship/ccflare
+cd ccflare
+
+# Build and run with Docker
+docker build -t ccflare .
+docker run -p 8080:8080 ccflare
+
+# Configure Claude SDK
+export ANTHROPIC_BASE_URL=http://localhost:8080
+```
+
+### With Bun
+
```bash
# Clone and install
git clone https://github.com/snipeship/ccflare
@@ -36,7 +53,8 @@ export ANTHROPIC_BASE_URL=http://localhost:8080
## Features
### 🎯 Intelligent Load Balancing
-- **Session-based** - Maintain conversation context (5hr sessions)
+- **Strategies Supported**:
+ - **session** – Maintain session stickiness for up to 5 hours per account.
### 📈 Real-Time Analytics
- Token usage tracking per request
@@ -55,6 +73,8 @@ export ANTHROPIC_BASE_URL=http://localhost:8080
- OAuth token refresh handling
- SQLite database for persistence
- Configurable retry logic
+- Authentication (default credentials are ccflare_user : ccflare_password)
+- Docker deployment
## Documentation
@@ -101,4 +121,4 @@ MIT - See [LICENSE](LICENSE) for details
Built with ❤️ for developers who ship
-
+
\ No newline at end of file
diff --git a/apps/tui/src/components/AccountsScreen.tsx b/apps/tui/src/components/AccountsScreen.tsx
index ec3c1c56..89bbc407 100644
--- a/apps/tui/src/components/AccountsScreen.tsx
+++ b/apps/tui/src/components/AccountsScreen.tsx
@@ -263,13 +263,15 @@ export function AccountsScreen({ onBack }: AccountsScreenProps) {
}
const menuItems = [
- ...accounts.map((acc) => {
- const presenter = new AccountPresenter(acc);
- return {
- label: `${acc.name} (${presenter.tierDisplay})`,
- value: `account:${acc.name}`,
- };
- }),
+ ...accounts
+ .sort((a, b) => (a.priority || 0) - (b.priority || 0))
+ .map((acc, index) => {
+ const presenter = new AccountPresenter(acc);
+ return {
+ label: `#${index + 1} ${acc.name} (${presenter.tierDisplay})`,
+ value: `account:${acc.name}`,
+ };
+ }),
{ label: "➕ Add Account", value: "add" },
{ label: "← Back", value: "back" },
];
diff --git a/apps/tui/src/components/RequestsScreen.tsx b/apps/tui/src/components/RequestsScreen.tsx
index 6f2812f0..3dfd3149 100644
--- a/apps/tui/src/components/RequestsScreen.tsx
+++ b/apps/tui/src/components/RequestsScreen.tsx
@@ -139,6 +139,10 @@ export function RequestsScreen({ onBack }: RequestsScreenProps) {
Account: {selectedRequest.meta.accountName}
)}
+ {selectedSummary?.clientIp && (
+ Client IP: {selectedSummary.clientIp}
+ )}
+
{selectedSummary?.model && (
Model: {selectedSummary.model}
diff --git a/bun.lock b/bun.lock
index 2fb8c879..0f0c74db 100644
--- a/bun.lock
+++ b/bun.lock
@@ -6,7 +6,7 @@
"devDependencies": {
"@biomejs/biome": "2.1.2",
"@types/bun": "latest",
- "bun-types": "latest",
+ "bun-types": "^1.2.19",
"typescript": "^5.0.0",
},
},
diff --git a/docs/api-http.md b/docs/api-http.md
index 20615b07..b51849cb 100644
--- a/docs/api-http.md
+++ b/docs/api-http.md
@@ -25,7 +25,7 @@ open http://localhost:8080/dashboard
## Overview
-ccflare provides a RESTful HTTP API for managing accounts, monitoring usage, and proxying requests to Claude. The API runs on port 8080 by default and requires no authentication.
+ccflare provides a RESTful HTTP API for managing accounts, monitoring usage, and proxying requests to Claude. The API runs on port 8080 by default. Dashboard access requires authentication while API endpoints remain open for compatibility.
### Base URL
@@ -804,6 +804,16 @@ The dashboard provides a visual interface for:
- Managing configuration
- Examining request history
+### Dashboard Authentication
+
+The dashboard requires authentication to access. Default credentials:
+- **Username**: `ccflare_user`
+- **Password**: `ccflare_password`
+
+Authentication is handled via a database-backed user system. The default user is created automatically on first run.
+
+**Note**: The previous environment variables `DASHBOARD_USERNAME` and `DASHBOARD_PASSWORD` are now deprecated and no longer used.
+
---
## Configuration
@@ -851,9 +861,62 @@ The following strategy is available:
**⚠️ WARNING:** Only use the session strategy. Other strategies can trigger Claude's anti-abuse systems and result in account bans.
+## Authentication
+
+### API Endpoints
+
+API endpoints (`/api/*`) do not require authentication for backward compatibility. They are designed for programmatic access and internal use.
+
+### Dashboard Authentication
+
+The web dashboard (`/dashboard`) does not require authentication, unless enabled with an environment variable `AUTH_ENABLED=(boolean)`. If enabled, default credentials are `ccflare_user` for the username, and `ccflare_password` for the password.
+
+#### POST /api/auth/login
+
+Login to the dashboard.
+
+**Request:**
+```json
+{
+ "username": "ccflare_user",
+ "password": "ccflare_password"
+}
+```
+
+**Response:**
+```json
+{
+ "success": true
+}
+```
+
+Sets an HTTP-only session cookie for authentication.
+
+#### POST /api/auth/logout
+
+Logout from the dashboard.
+
+**Response:**
+```json
+{
+ "success": true
+}
+```
+
+#### GET /api/auth/check
+
+Check authentication status.
+
+**Response:**
+```json
+{
+ "authenticated": true
+}
+```
+
## Notes
-1. **No Authentication**: The API endpoints do not require authentication. ccflare manages the OAuth tokens internally for proxying to Claude.
+1. **API Authentication**: The API endpoints do not require authentication. ccflare manages the OAuth tokens internally for proxying to Claude.
2. **Automatic Failover**: When a request fails or an account is rate limited, ccflare automatically tries the next available account. If no accounts are available, requests are forwarded without authentication as a fallback.
diff --git a/package.json b/package.json
index 38293e23..cafdb72d 100644
--- a/package.json
+++ b/package.json
@@ -26,8 +26,8 @@
},
"devDependencies": {
"@biomejs/biome": "2.1.2",
- "bun-types": "latest",
"@types/bun": "latest",
+ "bun-types": "^1.2.19",
"typescript": "^5.0.0"
},
"overrides": {
diff --git a/packages/cli-commands/src/commands/account.ts b/packages/cli-commands/src/commands/account.ts
index fc5cc383..7d383f57 100644
--- a/packages/cli-commands/src/commands/account.ts
+++ b/packages/cli-commands/src/commands/account.ts
@@ -138,6 +138,7 @@ export function getAccountsList(dbOps: DatabaseOperations): AccountListItem[] {
sessionInfo,
tier: account.account_tier || 1,
mode: account.account_tier > 1 ? "max" : "console",
+ priority: account.priority,
};
});
}
diff --git a/packages/core/src/pricing.ts b/packages/core/src/pricing.ts
index cfe7f75b..f68323c9 100644
--- a/packages/core/src/pricing.ts
+++ b/packages/core/src/pricing.ts
@@ -189,12 +189,12 @@ class PriceCatalogue {
return this.priceData;
}
- // Always attempt to fetch fresh pricing first (once per process start)
- let data = await this.fetchRemote();
+ // Try loading from disk cache
+ let data = await this.loadFromCache();
- // If remote fetch failed (offline or error), fall back to disk cache
+ // If no cache, fetch remote
if (!data) {
- data = await this.loadFromCache();
+ data = await this.fetchRemote();
}
// Fall back to bundled pricing
diff --git a/packages/dashboard-web/src/App.tsx b/packages/dashboard-web/src/App.tsx
index 360d3b87..c2143e25 100644
--- a/packages/dashboard-web/src/App.tsx
+++ b/packages/dashboard-web/src/App.tsx
@@ -1,13 +1,8 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
-import { useState } from "react";
-import { AccountsTab } from "./components/AccountsTab";
-import { AgentsTab } from "./components/AgentsTab";
-import { AnalyticsTab } from "./components/AnalyticsTab";
-import { LogsTab } from "./components/LogsTab";
-import { Navigation } from "./components/navigation";
-import { OverviewTab } from "./components/OverviewTab";
-import { RequestsTab } from "./components/RequestsTab";
+import { Dashboard } from "./components/Dashboard";
+import { LoginPage } from "./components/LoginPage";
import { QUERY_CONFIG, REFRESH_INTERVALS } from "./constants";
+import { AuthProvider, useAuth } from "./contexts/auth-context";
import { ThemeProvider } from "./contexts/theme-context";
import "./index.css";
@@ -20,74 +15,25 @@ const queryClient = new QueryClient({
},
});
-export function App() {
- const [activeTab, setActiveTab] = useState("overview");
+function AppContent() {
+ const { isAuthenticated, login, authEnabled } = useAuth();
+
+ // If auth is disabled, go directly to dashboard
+ // If auth is enabled but user is not authenticated, show login page
+ if (authEnabled && !isAuthenticated) {
+ return ;
+ }
- const renderContent = () => {
- switch (activeTab) {
- case "overview":
- return ;
- case "analytics":
- return ;
- case "requests":
- return ;
- case "accounts":
- return ;
- case "agents":
- return ;
- case "logs":
- return ;
- default:
- return ;
- }
- };
+ return ;
+}
+export function App() {
return (
-
-
-
- {/* Main Content */}
-
- {/* Mobile spacer */}
-
-
- {/* Page Content */}
-
- {/* Page Header */}
-
-
- {activeTab === "overview" && "Dashboard Overview"}
- {activeTab === "analytics" && "Analytics"}
- {activeTab === "requests" && "Request History"}
- {activeTab === "accounts" && "Account Management"}
- {activeTab === "agents" && "Agent Management"}
- {activeTab === "logs" && "System Logs"}
-
-
- {activeTab === "overview" &&
- "Monitor your ccflare performance and usage"}
- {activeTab === "analytics" &&
- "Deep dive into your usage patterns and trends"}
- {activeTab === "requests" &&
- "View detailed request and response data"}
- {activeTab === "accounts" &&
- "Manage your OAuth accounts and settings"}
- {activeTab === "agents" &&
- "Discover and manage Claude Code agents"}
- {activeTab === "logs" &&
- "Real-time system logs and debugging information"}
-
-
-
- {/* Tab Content */}
-
- {renderContent()}
-
-
-
-
+
+
+
);
diff --git a/packages/dashboard-web/src/api.ts b/packages/dashboard-web/src/api.ts
index f712ec58..94608daf 100644
--- a/packages/dashboard-web/src/api.ts
+++ b/packages/dashboard-web/src/api.ts
@@ -46,6 +46,14 @@ class API extends HttpClient {
});
}
+ // Override request to include credentials
+ async request(url: string, options?: RequestInit): Promise {
+ return super.request(url, {
+ ...options,
+ credentials: "include",
+ });
+ }
+
async getStats(): Promise {
return this.get("/api/stats");
}
@@ -297,6 +305,19 @@ class API extends HttpClient {
throw error;
}
}
+
+ async updateAccountsPriorities(
+ accountPriorities: Array<{ id: string; priority: number }>,
+ ): Promise {
+ try {
+ await this.post("/api/accounts/priorities", { accountPriorities });
+ } catch (error) {
+ if (error instanceof HttpError) {
+ throw new Error(error.message);
+ }
+ throw error;
+ }
+ }
}
export const api = new API();
diff --git a/packages/dashboard-web/src/components/AccountsTab.tsx b/packages/dashboard-web/src/components/AccountsTab.tsx
index d6e3b0c1..f23423d0 100644
--- a/packages/dashboard-web/src/components/AccountsTab.tsx
+++ b/packages/dashboard-web/src/components/AccountsTab.tsx
@@ -134,6 +134,21 @@ export function AccountsTab() {
}
};
+ const handleReorderAccounts = async (reorderedAccounts: Account[]) => {
+ try {
+ const accountPriorities = reorderedAccounts.map((account, index) => ({
+ id: account.id,
+ priority: index,
+ }));
+
+ await api.updateAccountsPriorities(accountPriorities);
+ await loadAccounts();
+ setActionError(null);
+ } catch (err) {
+ setActionError(formatError(err));
+ }
+ };
+
if (loading) {
return (
@@ -195,6 +210,7 @@ export function AccountsTab() {
onPauseToggle={handlePauseToggle}
onRemove={handleRemoveAccount}
onRename={handleRename}
+ onReorder={handleReorderAccounts}
/>
diff --git a/packages/dashboard-web/src/components/Dashboard.tsx b/packages/dashboard-web/src/components/Dashboard.tsx
new file mode 100644
index 00000000..de154048
--- /dev/null
+++ b/packages/dashboard-web/src/components/Dashboard.tsx
@@ -0,0 +1,77 @@
+import { useState } from "react";
+import { AccountsTab } from "./AccountsTab";
+import { AgentsTab } from "./AgentsTab";
+import { AnalyticsTab } from "./AnalyticsTab";
+import { LogsTab } from "./LogsTab";
+import { Navigation } from "./navigation";
+import { OverviewTab } from "./OverviewTab";
+import { RequestsTab } from "./RequestsTab";
+
+export function Dashboard() {
+ const [activeTab, setActiveTab] = useState("overview");
+
+ const renderContent = () => {
+ switch (activeTab) {
+ case "overview":
+ return ;
+ case "analytics":
+ return ;
+ case "requests":
+ return ;
+ case "accounts":
+ return ;
+ case "agents":
+ return ;
+ case "logs":
+ return ;
+ default:
+ return ;
+ }
+ };
+
+ return (
+
+
+
+ {/* Main Content */}
+
+ {/* Mobile spacer */}
+
+
+ {/* Page Content */}
+
+ {/* Page Header */}
+
+
+ {activeTab === "overview" && "Dashboard Overview"}
+ {activeTab === "analytics" && "Analytics"}
+ {activeTab === "requests" && "Request History"}
+ {activeTab === "accounts" && "Account Management"}
+ {activeTab === "agents" && "Agent Management"}
+ {activeTab === "logs" && "System Logs"}
+
+
+ {activeTab === "overview" &&
+ "Monitor your ccflare performance and usage"}
+ {activeTab === "analytics" &&
+ "Deep dive into your usage patterns and trends"}
+ {activeTab === "requests" &&
+ "View detailed request and response data"}
+ {activeTab === "accounts" &&
+ "Manage your OAuth accounts and settings"}
+ {activeTab === "agents" &&
+ "Discover and manage Claude Code agents"}
+ {activeTab === "logs" &&
+ "Real-time system logs and debugging information"}
+
+
+
+ {/* Tab Content */}
+
+ {renderContent()}
+
+
+
+
+ );
+}
diff --git a/packages/dashboard-web/src/components/LoginPage.tsx b/packages/dashboard-web/src/components/LoginPage.tsx
new file mode 100644
index 00000000..c390fcc0
--- /dev/null
+++ b/packages/dashboard-web/src/components/LoginPage.tsx
@@ -0,0 +1,90 @@
+import { useState } from "react";
+import { Button } from "./ui/button";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "./ui/card";
+import { Input } from "./ui/input";
+import { Label } from "./ui/label";
+
+interface LoginPageProps {
+ onLogin: (username: string, password: string) => Promise;
+}
+
+export function LoginPage({ onLogin }: LoginPageProps) {
+ const [username, setUsername] = useState("");
+ const [password, setPassword] = useState("");
+ const [isLoading, setIsLoading] = useState(false);
+ const [error, setError] = useState("");
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ setError("");
+ setIsLoading(true);
+
+ try {
+ const success = await onLogin(username, password);
+ if (!success) {
+ setError("Invalid username or password");
+ }
+ } catch (_err) {
+ setError("An error occurred. Please try again.");
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ return (
+
+
+
+
+ Sign in to ccflare
+
+
+ Enter your credentials to access the dashboard
+
+
+
+
+
+
+
+ );
+}
diff --git a/packages/dashboard-web/src/components/RequestsTab.tsx b/packages/dashboard-web/src/components/RequestsTab.tsx
index 7565e22e..02d9c1fe 100644
--- a/packages/dashboard-web/src/components/RequestsTab.tsx
+++ b/packages/dashboard-web/src/components/RequestsTab.tsx
@@ -726,6 +726,7 @@ export function RequestsTab() {
Retry {request.meta.retry}
)}
ID: {request.id.slice(0, 8)}...
+ {summary?.clientIp && IP: {summary.clientIp}}
diff --git a/packages/dashboard-web/src/components/accounts/AccountList.tsx b/packages/dashboard-web/src/components/accounts/AccountList.tsx
index 2c6c0f4b..ea89c716 100644
--- a/packages/dashboard-web/src/components/accounts/AccountList.tsx
+++ b/packages/dashboard-web/src/components/accounts/AccountList.tsx
@@ -1,3 +1,4 @@
+import { useState } from "react";
import type { Account } from "../../api";
import { AccountListItem } from "./AccountListItem";
@@ -6,6 +7,7 @@ interface AccountListProps {
onPauseToggle: (account: Account) => void;
onRemove: (name: string) => void;
onRename: (account: Account) => void;
+ onReorder?: (accounts: Account[]) => void;
}
export function AccountList({
@@ -13,11 +15,30 @@ export function AccountList({
onPauseToggle,
onRemove,
onRename,
+ onReorder,
}: AccountListProps) {
+ const [draggedAccount, setDraggedAccount] = useState(null);
+ const [dragOverIndex, setDragOverIndex] = useState(null);
+
if (!accounts || accounts.length === 0) {
return No accounts configured
;
}
+ // Sort accounts by priority first, then by most recently used
+ const sortedAccounts = [...accounts].sort((a, b) => {
+ // Primary sort: priority (lower values = higher priority)
+ if (a.priority !== b.priority) {
+ return (a.priority || 0) - (b.priority || 0);
+ }
+ // Secondary sort: most recently used
+ if (a.lastUsed && b.lastUsed) {
+ return new Date(b.lastUsed).getTime() - new Date(a.lastUsed).getTime();
+ }
+ if (a.lastUsed) return -1;
+ if (b.lastUsed) return 1;
+ return 0;
+ });
+
// Find the most recently used account
const mostRecentAccountId = accounts.reduce(
(mostRecent, account) => {
@@ -35,17 +56,84 @@ export function AccountList({
null as string | null,
);
+ const handleDragStart = (account: Account) => {
+ setDraggedAccount(account);
+ };
+
+ const handleDragEnd = () => {
+ setDraggedAccount(null);
+ setDragOverIndex(null);
+ };
+
+ const handleDragOver = (e: React.DragEvent, index: number) => {
+ e.preventDefault();
+ setDragOverIndex(index);
+ };
+
+ const handleDrop = (e: React.DragEvent, dropIndex: number) => {
+ e.preventDefault();
+ if (!draggedAccount || !onReorder) return;
+
+ const draggedIndex = sortedAccounts.findIndex(
+ (acc) => acc.id === draggedAccount.id,
+ );
+ if (draggedIndex === dropIndex) return;
+
+ // Create new array with reordered accounts
+ const newAccounts = [...sortedAccounts];
+ newAccounts.splice(draggedIndex, 1);
+ newAccounts.splice(dropIndex, 0, draggedAccount);
+
+ // Update priorities based on new order
+ const accountsWithNewPriorities = newAccounts.map((account, index) => ({
+ ...account,
+ priority: index,
+ }));
+
+ onReorder(accountsWithNewPriorities);
+ setDraggedAccount(null);
+ setDragOverIndex(null);
+ };
+
return (
- {accounts.map((account) => (
-
+ 💡 Drag and drop accounts to reorder them. Higher accounts will be
+ prioritized.
+
+ )}
+ {sortedAccounts.map((account, index) => (
+
+ draggable={!!onReorder}
+ onDragStart={() => handleDragStart(account)}
+ onDragEnd={handleDragEnd}
+ onDragOver={(e) => handleDragOver(e, index)}
+ onDrop={(e) => handleDrop(e, index)}
+ className={`
+ ${onReorder ? "cursor-move" : ""}
+ ${draggedAccount?.id === account.id ? "opacity-50" : ""}
+ ${dragOverIndex === index ? "ring-2 ring-blue-500 ring-offset-2" : ""}
+ `}
+ >
+
+ {onReorder && (
+
+ #{index + 1}
+
+ )}
+
+
+
))}
);
diff --git a/packages/dashboard-web/src/components/navigation.tsx b/packages/dashboard-web/src/components/navigation.tsx
index 813da1c6..9e8a1407 100644
--- a/packages/dashboard-web/src/components/navigation.tsx
+++ b/packages/dashboard-web/src/components/navigation.tsx
@@ -5,6 +5,7 @@ import {
FileText,
GitBranch,
LayoutDashboard,
+ LogOut,
Menu,
Shield,
Users,
@@ -12,6 +13,7 @@ import {
Zap,
} from "lucide-react";
import { useState } from "react";
+import { useAuth } from "../contexts/auth-context";
import { cn } from "../lib/utils";
import { ThemeToggle } from "./theme-toggle";
import { Button } from "./ui/button";
@@ -40,6 +42,7 @@ interface NavigationProps {
export function Navigation({ activeTab, onTabChange }: NavigationProps) {
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
+ const { logout } = useAuth();
return (
<>
@@ -147,6 +150,15 @@ export function Navigation({ activeTab, onTabChange }: NavigationProps) {
+
+
diff --git a/packages/dashboard-web/src/contexts/auth-context.tsx b/packages/dashboard-web/src/contexts/auth-context.tsx
new file mode 100644
index 00000000..f5505c7c
--- /dev/null
+++ b/packages/dashboard-web/src/contexts/auth-context.tsx
@@ -0,0 +1,101 @@
+import {
+ createContext,
+ type ReactNode,
+ useContext,
+ useEffect,
+ useState,
+} from "react";
+
+interface AuthContextType {
+ isAuthenticated: boolean;
+ login: (username: string, password: string) => Promise
;
+ logout: () => void;
+ authEnabled: boolean;
+}
+
+const AuthContext = createContext(undefined);
+
+export function AuthProvider({ children }: { children: ReactNode }) {
+ const [isAuthenticated, setIsAuthenticated] = useState(false);
+ const [isLoading, setIsLoading] = useState(true);
+ const [authEnabled, setAuthEnabled] = useState(true);
+
+ // Check if already authenticated on mount
+ useEffect(() => {
+ const checkAuth = async () => {
+ try {
+ const response = await fetch("/api/auth/check", {
+ credentials: "include",
+ });
+
+ const data = await response.json();
+ setIsAuthenticated(data.authenticated);
+
+ // Check if auth is enabled by looking for authEnabled field in response
+ // If not present, assume auth is enabled for backward compatibility
+ setAuthEnabled(data.authEnabled !== false);
+ } catch {
+ setIsAuthenticated(false);
+ setAuthEnabled(true); // Assume auth is enabled on error
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ checkAuth();
+ }, []);
+
+ const login = async (
+ username: string,
+ password: string,
+ ): Promise => {
+ try {
+ const response = await fetch("/api/auth/login", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ username, password }),
+ credentials: "include",
+ });
+
+ if (response.ok) {
+ setIsAuthenticated(true);
+ return true;
+ }
+ return false;
+ } catch {
+ return false;
+ }
+ };
+
+ const logout = async () => {
+ try {
+ await fetch("/api/auth/logout", {
+ method: "POST",
+ credentials: "include",
+ });
+ } catch {
+ // Ignore errors
+ }
+ setIsAuthenticated(false);
+ };
+
+ if (isLoading) {
+ return ;
+ }
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function useAuth() {
+ const context = useContext(AuthContext);
+ if (context === undefined) {
+ throw new Error("useAuth must be used within an AuthProvider");
+ }
+ return context;
+}
diff --git a/packages/database/src/database-operations.ts b/packages/database/src/database-operations.ts
index 2db7aaa0..db580b59 100644
--- a/packages/database/src/database-operations.ts
+++ b/packages/database/src/database-operations.ts
@@ -14,6 +14,7 @@ import {
} from "./repositories/request.repository";
import { StatsRepository } from "./repositories/stats.repository";
import { StrategyRepository } from "./repositories/strategy.repository";
+import { UserRepository } from "./repositories/user.repository";
export interface RuntimeConfig {
sessionDurationMs?: number;
@@ -34,6 +35,7 @@ export class DatabaseOperations implements StrategyStore, Disposable {
private strategy: StrategyRepository;
private stats: StatsRepository;
private agentPreferences: AgentPreferenceRepository;
+ private users: UserRepository;
constructor(dbPath?: string) {
const resolvedPath = dbPath ?? resolveDbPath();
@@ -59,6 +61,10 @@ export class DatabaseOperations implements StrategyStore, Disposable {
this.strategy = new StrategyRepository(this.db);
this.stats = new StatsRepository(this.db);
this.agentPreferences = new AgentPreferenceRepository(this.db);
+ this.users = new UserRepository(this.db);
+
+ // Initialize default user on first run
+ this.users.initializeDefaultUser();
}
setRuntimeConfig(runtime: RuntimeConfig): void {
@@ -122,6 +128,16 @@ export class DatabaseOperations implements StrategyStore, Disposable {
this.accounts.rename(accountId, newName);
}
+ updateAccountPriority(accountId: string, priority: number): void {
+ this.accounts.updatePriority(accountId, priority);
+ }
+
+ updateAccountsPriorities(
+ accountPriorities: Array<{ id: string; priority: number }>,
+ ): void {
+ this.accounts.updateAccountsPriorities(accountPriorities);
+ }
+
resetAccountSession(accountId: string, timestamp: number): void {
this.accounts.resetSession(accountId, timestamp);
}
@@ -138,6 +154,7 @@ export class DatabaseOperations implements StrategyStore, Disposable {
accountUsed: string | null,
statusCode: number | null,
timestamp?: number,
+ clientIp?: string,
): void {
this.requests.saveMeta(
id,
@@ -146,6 +163,7 @@ export class DatabaseOperations implements StrategyStore, Disposable {
accountUsed,
statusCode,
timestamp,
+ clientIp,
);
}
@@ -161,6 +179,7 @@ export class DatabaseOperations implements StrategyStore, Disposable {
failoverAttempts: number,
usage?: RequestData["usage"],
agentUsed?: string,
+ clientIp?: string,
): void {
this.requests.save({
id,
@@ -174,6 +193,7 @@ export class DatabaseOperations implements StrategyStore, Disposable {
failoverAttempts,
usage,
agentUsed,
+ clientIp,
});
}
@@ -347,4 +367,16 @@ export class DatabaseOperations implements StrategyStore, Disposable {
getStatsRepository(): StatsRepository {
return this.stats;
}
+
+ // User operations delegated to repository
+ authenticateUser(username: string, password: string): boolean {
+ const user = this.users.findByUsername(username);
+ if (!user) return false;
+
+ const isValid = this.users.verifyPassword(password, user.passwordHash);
+ if (isValid) {
+ this.users.updateLastLogin(user.id);
+ }
+ return isValid;
+ }
}
diff --git a/packages/database/src/migrations.ts b/packages/database/src/migrations.ts
index 0afb29eb..af3df1e6 100644
--- a/packages/database/src/migrations.ts
+++ b/packages/database/src/migrations.ts
@@ -90,6 +90,20 @@ export function ensureSchema(db: Database): void {
updated_at INTEGER NOT NULL
)
`);
+
+ // Create users table for dashboard authentication
+ db.run(`
+ CREATE TABLE IF NOT EXISTS users (
+ id TEXT PRIMARY KEY,
+ username TEXT UNIQUE NOT NULL,
+ password_hash TEXT NOT NULL,
+ created_at INTEGER NOT NULL,
+ last_login INTEGER
+ )
+ `);
+
+ // Create index for faster username lookups
+ db.run(`CREATE INDEX IF NOT EXISTS idx_users_username ON users(username)`);
}
export function runMigrations(db: Database): void {
@@ -170,6 +184,14 @@ export function runMigrations(db: Database): void {
log.info("Added rate_limit_remaining column to accounts table");
}
+ // Add priority column if it doesn't exist
+ if (!accountsColumnNames.includes("priority")) {
+ db.prepare(
+ "ALTER TABLE accounts ADD COLUMN priority INTEGER DEFAULT 0",
+ ).run();
+ log.info("Added priority column to accounts table");
+ }
+
// Check columns in requests table
const requestsInfo = db
.prepare("PRAGMA table_info(requests)")
@@ -267,6 +289,12 @@ export function runMigrations(db: Database): void {
log.info("Added output_tokens_per_second column to requests table");
}
+ // Add client_ip column if it doesn't exist
+ if (!requestsColumnNames.includes("client_ip")) {
+ db.prepare("ALTER TABLE requests ADD COLUMN client_ip TEXT").run();
+ log.info("Added client_ip column to requests table");
+ }
+
// Add performance indexes
addPerformanceIndexes(db);
}
diff --git a/packages/database/src/repositories/account.repository.ts b/packages/database/src/repositories/account.repository.ts
index 4aedd72d..d7d7913a 100644
--- a/packages/database/src/repositories/account.repository.ts
+++ b/packages/database/src/repositories/account.repository.ts
@@ -10,8 +10,10 @@ export class AccountRepository extends BaseRepository {
rate_limited_until, session_start, session_request_count,
COALESCE(account_tier, 1) as account_tier,
COALESCE(paused, 0) as paused,
- rate_limit_reset, rate_limit_status, rate_limit_remaining
+ rate_limit_reset, rate_limit_status, rate_limit_remaining,
+ COALESCE(priority, 0) as priority
FROM accounts
+ ORDER BY priority ASC, created_at ASC
`);
return rows.map(toAccount);
}
@@ -25,7 +27,8 @@ export class AccountRepository extends BaseRepository {
rate_limited_until, session_start, session_request_count,
COALESCE(account_tier, 1) as account_tier,
COALESCE(paused, 0) as paused,
- rate_limit_reset, rate_limit_status, rate_limit_remaining
+ rate_limit_reset, rate_limit_status, rate_limit_remaining,
+ COALESCE(priority, 0) as priority
FROM accounts
WHERE id = ?
`,
@@ -128,4 +131,25 @@ export class AccountRepository extends BaseRepository {
rename(accountId: string, newName: string): void {
this.run(`UPDATE accounts SET name = ? WHERE id = ?`, [newName, accountId]);
}
+
+ updatePriority(accountId: string, priority: number): void {
+ this.run(`UPDATE accounts SET priority = ? WHERE id = ?`, [
+ priority,
+ accountId,
+ ]);
+ }
+
+ updateAccountsPriorities(
+ accountPriorities: Array<{ id: string; priority: number }>,
+ ): void {
+ const transaction = this.db.transaction(() => {
+ for (const { id, priority } of accountPriorities) {
+ this.run(`UPDATE accounts SET priority = ? WHERE id = ?`, [
+ priority,
+ id,
+ ]);
+ }
+ });
+ transaction();
+ }
}
diff --git a/packages/database/src/repositories/request.repository.ts b/packages/database/src/repositories/request.repository.ts
index 0b37b5a0..4743f1fd 100644
--- a/packages/database/src/repositories/request.repository.ts
+++ b/packages/database/src/repositories/request.repository.ts
@@ -11,6 +11,7 @@ export interface RequestData {
responseTime: number;
failoverAttempts: number;
agentUsed?: string;
+ clientIp?: string;
usage?: {
model?: string;
promptTokens?: number;
@@ -33,16 +34,25 @@ export class RequestRepository extends BaseRepository {
accountUsed: string | null,
statusCode: number | null,
timestamp?: number,
+ clientIp?: string,
): void {
this.run(
`
INSERT INTO requests (
id, timestamp, method, path, account_used,
- status_code, success, error_message, response_time_ms, failover_attempts
+ status_code, success, error_message, response_time_ms, failover_attempts, client_ip
)
- VALUES (?, ?, ?, ?, ?, ?, 0, NULL, 0, 0)
+ VALUES (?, ?, ?, ?, ?, ?, 0, NULL, 0, 0, ?)
`,
- [id, timestamp || Date.now(), method, path, accountUsed, statusCode],
+ [
+ id,
+ timestamp || Date.now(),
+ method,
+ path,
+ accountUsed,
+ statusCode,
+ clientIp || null,
+ ],
);
}
@@ -55,9 +65,9 @@ export class RequestRepository extends BaseRepository {
status_code, success, error_message, response_time_ms, failover_attempts,
model, prompt_tokens, completion_tokens, total_tokens, cost_usd,
input_tokens, cache_read_input_tokens, cache_creation_input_tokens, output_tokens,
- agent_used, output_tokens_per_second
+ agent_used, output_tokens_per_second, client_ip
)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
[
data.id,
@@ -81,6 +91,7 @@ export class RequestRepository extends BaseRepository {
usage?.outputTokens || null,
data.agentUsed || null,
usage?.tokensPerSecond || null,
+ data.clientIp || null,
],
);
}
diff --git a/packages/database/src/repositories/user.repository.ts b/packages/database/src/repositories/user.repository.ts
new file mode 100644
index 00000000..5e65e829
--- /dev/null
+++ b/packages/database/src/repositories/user.repository.ts
@@ -0,0 +1,59 @@
+import crypto from "node:crypto";
+import { BaseRepository } from "./base.repository";
+
+export interface User {
+ id: string;
+ username: string;
+ passwordHash: string;
+ createdAt: number;
+ lastLogin: number | null;
+}
+
+export class UserRepository extends BaseRepository {
+ findByUsername(username: string): User | null {
+ return this.get(
+ "SELECT id, username, password_hash as passwordHash, created_at as createdAt, last_login as lastLogin FROM users WHERE username = ?",
+ [username],
+ );
+ }
+
+ create(username: string, password: string): void {
+ const id = crypto.randomUUID();
+ const passwordHash = this.hashPassword(password);
+ const createdAt = Date.now();
+
+ this.run(
+ "INSERT INTO users (id, username, password_hash, created_at) VALUES (?, ?, ?, ?)",
+ [id, username, passwordHash, createdAt],
+ );
+ }
+
+ updateLastLogin(userId: string): void {
+ this.run("UPDATE users SET last_login = ? WHERE id = ?", [
+ Date.now(),
+ userId,
+ ]);
+ }
+
+ verifyPassword(password: string, passwordHash: string): boolean {
+ const hash = this.hashPassword(password);
+ return hash === passwordHash;
+ }
+
+ private hashPassword(password: string): string {
+ // Using SHA-256 for simplicity, but in production you should use bcrypt or argon2
+ return crypto.createHash("sha256").update(password).digest("hex");
+ }
+
+ // Initialize default user if no users exist
+ initializeDefaultUser(): void {
+ const userCount = this.get<{ count: number }>(
+ "SELECT COUNT(*) as count FROM users",
+ [],
+ );
+
+ if (userCount && userCount.count === 0) {
+ this.create("ccflare_user", "ccflare_password");
+ }
+ }
+}
diff --git a/packages/http-api/src/handlers/accounts.ts b/packages/http-api/src/handlers/accounts.ts
index f1e4785d..5290de16 100644
--- a/packages/http-api/src/handlers/accounts.ts
+++ b/packages/http-api/src/handlers/accounts.ts
@@ -48,6 +48,7 @@ export function createAccountsListHandler(db: Database) {
session_request_count,
COALESCE(account_tier, 1) as account_tier,
COALESCE(paused, 0) as paused,
+ COALESCE(priority, 0) as priority,
CASE
WHEN expires_at > ?1 THEN 1
ELSE 0
@@ -62,7 +63,7 @@ export function createAccountsListHandler(db: Database) {
ELSE '-'
END as session_info
FROM accounts
- ORDER BY request_count DESC
+ ORDER BY priority ASC, request_count DESC
`,
)
.all(now, now, now, sessionDuration) as Array<{
@@ -82,6 +83,7 @@ export function createAccountsListHandler(db: Database) {
session_request_count: number;
account_tier: number;
paused: 0 | 1;
+ priority: number;
token_valid: 0 | 1;
rate_limited: 0 | 1;
session_info: string | null;
@@ -133,6 +135,7 @@ export function createAccountsListHandler(db: Database) {
: null,
rateLimitRemaining: account.rate_limit_remaining,
sessionInfo: account.session_info || "",
+ priority: account.priority,
};
});
@@ -434,3 +437,58 @@ export function createAccountRenameHandler(dbOps: DatabaseOperations) {
}
};
}
+
+/**
+ * Create an accounts priority update handler
+ */
+export function createAccountsPriorityUpdateHandler(dbOps: DatabaseOperations) {
+ return async (req: Request): Promise => {
+ try {
+ const body = await req.json();
+
+ // Validate the request body
+ if (!Array.isArray(body.accountPriorities)) {
+ return errorResponse(BadRequest("accountPriorities must be an array"));
+ }
+
+ const accountPriorities = body.accountPriorities as Array<{
+ id: string;
+ priority: number;
+ }>;
+
+ // Validate each account priority entry
+ for (const entry of accountPriorities) {
+ const accountId = validateString(entry.id, "id", {
+ required: true,
+ minLength: 1,
+ });
+
+ const priority = validateNumber(entry.priority, "priority", {
+ required: true,
+ min: 0,
+ });
+
+ if (!accountId || priority === undefined) {
+ return errorResponse(
+ BadRequest("Each entry must have a valid id and priority"),
+ );
+ }
+ }
+
+ // Update priorities using database operations method
+ dbOps.updateAccountsPriorities(accountPriorities);
+
+ return jsonResponse({
+ success: true,
+ message: "Account priorities updated successfully",
+ });
+ } catch (error) {
+ log.error("Account priorities update error:", error);
+ return errorResponse(
+ error instanceof Error
+ ? error
+ : new Error("Failed to update account priorities"),
+ );
+ }
+ };
+}
diff --git a/packages/http-api/src/handlers/auth.ts b/packages/http-api/src/handlers/auth.ts
new file mode 100644
index 00000000..d6919a0a
--- /dev/null
+++ b/packages/http-api/src/handlers/auth.ts
@@ -0,0 +1,139 @@
+import crypto from "node:crypto";
+import type { DatabaseOperations } from "@ccflare/database";
+
+// Session storage (in production, use a proper session store)
+const sessions = new Map();
+
+// Generate a secure session token
+function generateSessionToken(): string {
+ return crypto.randomBytes(32).toString("hex");
+}
+
+// Clean expired sessions
+function cleanExpiredSessions() {
+ const now = Date.now();
+ for (const [token, session] of sessions.entries()) {
+ if (session.expires < now) {
+ sessions.delete(token);
+ }
+ }
+}
+
+// Verify session
+function verifySession(token: string | null): boolean {
+ if (!token) return false;
+ cleanExpiredSessions();
+ const session = sessions.get(token);
+ return session ? session.expires > Date.now() : false;
+}
+
+// Get session token from request
+function getSessionToken(req: Request): string | null {
+ const cookie = req.headers.get("cookie");
+ if (!cookie) return null;
+
+ const match = cookie.match(/session=([^;]+)/);
+ return match ? match[1] : null;
+}
+
+export const createLoginHandler = (db: DatabaseOperations) => {
+ return async (req: Request): Promise => {
+ const body = (await req.json()) as { username: string; password: string };
+ const { username, password } = body;
+
+ // Validate credentials against database
+ const isValid = db.authenticateUser(username, password);
+
+ if (!isValid) {
+ return new Response(JSON.stringify({ error: "Invalid credentials" }), {
+ status: 401,
+ headers: { "Content-Type": "application/json" },
+ });
+ }
+
+ // Create session
+ const token = generateSessionToken();
+ const expires = Date.now() + 24 * 60 * 60 * 1000; // 24 hours
+ sessions.set(token, { username, expires });
+
+ // Set session cookie
+ return new Response(JSON.stringify({ success: true }), {
+ status: 200,
+ headers: {
+ "Content-Type": "application/json",
+ "Set-Cookie": `session=${token}; Path=/; HttpOnly; SameSite=Strict; Max-Age=86400`,
+ },
+ });
+ };
+};
+
+export const createLogoutHandler = () => {
+ return async (req: Request): Promise => {
+ const token = getSessionToken(req);
+ if (token) {
+ sessions.delete(token);
+ }
+
+ return new Response(JSON.stringify({ success: true }), {
+ status: 200,
+ headers: {
+ "Content-Type": "application/json",
+ "Set-Cookie": "session=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0",
+ },
+ });
+ };
+};
+
+export const createAuthCheckHandler = () => {
+ return async (req: Request): Promise => {
+ // Check if auth is enabled via environment variable
+ const authEnabled = process.env.AUTH_ENABLED === 'true';
+
+ // If auth is disabled, always return authenticated: true with authEnabled: false
+ if (!authEnabled) {
+ return new Response(JSON.stringify({ authenticated: true, authEnabled: false }), {
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ });
+ }
+
+ const token = getSessionToken(req);
+ const isValid = verifySession(token);
+
+ if (!isValid) {
+ return new Response(JSON.stringify({ authenticated: false, authEnabled: true }), {
+ status: 401,
+ headers: { "Content-Type": "application/json" },
+ });
+ }
+
+ return new Response(JSON.stringify({ authenticated: true, authEnabled: true }), {
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ });
+ };
+};
+
+// Middleware to check authentication for API routes
+export function requireAuth(
+ handler: (req: Request, url: URL) => Response | Promise,
+) {
+ return async (req: Request, url: URL): Promise => {
+ // Check if auth is enabled via environment variable
+ const authEnabled = process.env.AUTH_ENABLED === 'true';
+
+ // Skip auth check if disabled
+ if (!authEnabled) {
+ return handler(req, url);
+ }
+
+ const token = getSessionToken(req);
+ if (!verifySession(token)) {
+ return new Response(JSON.stringify({ error: "Unauthorized" }), {
+ status: 401,
+ headers: { "Content-Type": "application/json" },
+ });
+ }
+ return handler(req, url);
+ };
+}
diff --git a/packages/http-api/src/handlers/requests.ts b/packages/http-api/src/handlers/requests.ts
index cc584073..25b160b2 100644
--- a/packages/http-api/src/handlers/requests.ts
+++ b/packages/http-api/src/handlers/requests.ts
@@ -41,6 +41,7 @@ export function createRequestsSummaryHandler(db: Database) {
cost_usd: number | null;
agent_used: string | null;
output_tokens_per_second: number | null;
+ client_ip: string | null;
}>;
const response: RequestResponse[] = requests.map((request) => ({
@@ -66,6 +67,7 @@ export function createRequestsSummaryHandler(db: Database) {
costUsd: request.cost_usd || undefined,
agentUsed: request.agent_used || undefined,
tokensPerSecond: request.output_tokens_per_second || undefined,
+ clientIp: request.client_ip || undefined,
}));
return jsonResponse(response);
diff --git a/packages/http-api/src/router.ts b/packages/http-api/src/router.ts
index 22f0fc6f..4c41a344 100644
--- a/packages/http-api/src/router.ts
+++ b/packages/http-api/src/router.ts
@@ -6,6 +6,7 @@ import {
createAccountRenameHandler,
createAccountResumeHandler,
createAccountsListHandler,
+ createAccountsPriorityUpdateHandler,
createAccountTierUpdateHandler,
} from "./handlers/accounts";
import {
@@ -16,6 +17,12 @@ import {
} from "./handlers/agents";
import { createAgentUpdateHandler } from "./handlers/agents-update";
import { createAnalyticsHandler } from "./handlers/analytics";
+import {
+ createAuthCheckHandler,
+ createLoginHandler,
+ createLogoutHandler,
+ requireAuth,
+} from "./handlers/auth";
import { createConfigHandlers } from "./handlers/config";
import { createHealthHandler } from "./handlers/health";
import { createLogsStreamHandler } from "./handlers/logs";
@@ -71,69 +78,134 @@ export class APIRouter {
const agentsHandler = createAgentsListHandler(dbOps);
const workspacesHandler = createWorkspacesListHandler();
const requestsStreamHandler = createRequestsStreamHandler();
+ const loginHandler = createLoginHandler(dbOps);
+ const logoutHandler = createLogoutHandler();
+ const authCheckHandler = createAuthCheckHandler();
// Register routes
this.handlers.set("GET:/health", () => healthHandler());
- this.handlers.set("GET:/api/stats", () => statsHandler());
- this.handlers.set("POST:/api/stats/reset", () => statsResetHandler());
- this.handlers.set("GET:/api/accounts", () => accountsHandler());
- this.handlers.set("POST:/api/accounts", (req) => accountAddHandler(req));
- this.handlers.set("POST:/api/oauth/init", (req) => oauthInitHandler(req));
- this.handlers.set("POST:/api/oauth/callback", (req) =>
- oauthCallbackHandler(req),
- );
- this.handlers.set("GET:/api/requests", (_req, url) => {
- const limitParam = url.searchParams.get("limit");
- const limit =
- validateNumber(limitParam || "50", "limit", {
- min: 1,
- max: 1000,
- integer: true,
- }) || 50;
- return requestsSummaryHandler(limit);
- });
- this.handlers.set("GET:/api/requests/detail", (_req, url) => {
- const limitParam = url.searchParams.get("limit");
- const limit =
- validateNumber(limitParam || "100", "limit", {
- min: 1,
- max: 1000,
- integer: true,
- }) || 100;
- return requestsDetailHandler(limit);
- });
- this.handlers.set("GET:/api/requests/stream", () =>
- requestsStreamHandler(),
- );
- this.handlers.set("GET:/api/config", () => configHandlers.getConfig());
- this.handlers.set("GET:/api/config/strategy", () =>
- configHandlers.getStrategy(),
- );
- this.handlers.set("POST:/api/config/strategy", (req) =>
- configHandlers.setStrategy(req),
- );
- this.handlers.set("GET:/api/strategies", () =>
- configHandlers.getStrategies(),
- );
- this.handlers.set("GET:/api/config/model", () =>
- configHandlers.getDefaultAgentModel(),
- );
- this.handlers.set("POST:/api/config/model", (req) =>
- configHandlers.setDefaultAgentModel(req),
- );
- this.handlers.set("GET:/api/logs/stream", () => logsStreamHandler());
- this.handlers.set("GET:/api/logs/history", () => logsHistoryHandler());
- this.handlers.set("GET:/api/analytics", (_req, url) => {
- return analyticsHandler(url.searchParams);
- });
- this.handlers.set("GET:/api/agents", () => agentsHandler());
- this.handlers.set("POST:/api/agents/bulk-preference", (req) => {
- const bulkHandler = createBulkAgentPreferenceUpdateHandler(
- this.context.dbOps,
- );
- return bulkHandler(req);
- });
- this.handlers.set("GET:/api/workspaces", () => workspacesHandler());
+
+ // Auth routes (no auth required)
+ this.handlers.set("POST:/api/auth/login", (req) => loginHandler(req));
+ this.handlers.set("POST:/api/auth/logout", (req) => logoutHandler(req));
+ this.handlers.set("GET:/api/auth/check", (req) => authCheckHandler(req));
+ // Protected routes (require authentication)
+ this.handlers.set(
+ "GET:/api/stats",
+ requireAuth(() => statsHandler()),
+ );
+ this.handlers.set(
+ "POST:/api/stats/reset",
+ requireAuth(() => statsResetHandler()),
+ );
+ this.handlers.set(
+ "GET:/api/accounts",
+ requireAuth(() => accountsHandler()),
+ );
+ this.handlers.set(
+ "POST:/api/accounts",
+ requireAuth((req) => accountAddHandler(req)),
+ );
+ this.handlers.set(
+ "POST:/api/accounts/priorities",
+ requireAuth((req) => {
+ const priorityHandler = createAccountsPriorityUpdateHandler(dbOps);
+ return priorityHandler(req);
+ }),
+ );
+ this.handlers.set(
+ "POST:/api/oauth/init",
+ requireAuth((req) => oauthInitHandler(req)),
+ );
+ this.handlers.set(
+ "POST:/api/oauth/callback",
+ requireAuth((req) => oauthCallbackHandler(req)),
+ );
+ this.handlers.set(
+ "GET:/api/requests",
+ requireAuth((_req, url) => {
+ const limitParam = url.searchParams.get("limit");
+ const limit =
+ validateNumber(limitParam || "50", "limit", {
+ min: 1,
+ max: 1000,
+ integer: true,
+ }) || 50;
+ return requestsSummaryHandler(limit);
+ }),
+ );
+ this.handlers.set(
+ "GET:/api/requests/detail",
+ requireAuth((_req, url) => {
+ const limitParam = url.searchParams.get("limit");
+ const limit =
+ validateNumber(limitParam || "100", "limit", {
+ min: 1,
+ max: 1000,
+ integer: true,
+ }) || 100;
+ return requestsDetailHandler(limit);
+ }),
+ );
+ this.handlers.set(
+ "GET:/api/requests/stream",
+ requireAuth(() => requestsStreamHandler()),
+ );
+ this.handlers.set(
+ "GET:/api/config",
+ requireAuth(() => configHandlers.getConfig()),
+ );
+ this.handlers.set(
+ "GET:/api/config/strategy",
+ requireAuth(() => configHandlers.getStrategy()),
+ );
+ this.handlers.set(
+ "POST:/api/config/strategy",
+ requireAuth((req) => configHandlers.setStrategy(req)),
+ );
+ this.handlers.set(
+ "GET:/api/strategies",
+ requireAuth(() => configHandlers.getStrategies()),
+ );
+ this.handlers.set(
+ "GET:/api/config/model",
+ requireAuth(() => configHandlers.getDefaultAgentModel()),
+ );
+ this.handlers.set(
+ "POST:/api/config/model",
+ requireAuth((req) => configHandlers.setDefaultAgentModel(req)),
+ );
+ this.handlers.set(
+ "GET:/api/logs/stream",
+ requireAuth(() => logsStreamHandler()),
+ );
+ this.handlers.set(
+ "GET:/api/logs/history",
+ requireAuth(() => logsHistoryHandler()),
+ );
+ this.handlers.set(
+ "GET:/api/analytics",
+ requireAuth((_req, url) => {
+ return analyticsHandler(url.searchParams);
+ }),
+ );
+ this.handlers.set(
+ "GET:/api/agents",
+ requireAuth(() => agentsHandler()),
+ );
+ this.handlers.set(
+ "POST:/api/agents/bulk-preference",
+ requireAuth((req) => {
+ const bulkHandler = createBulkAgentPreferenceUpdateHandler(
+ this.context.dbOps,
+ );
+ return bulkHandler(req);
+ }),
+ );
+ this.handlers.set(
+ "GET:/api/workspaces",
+ requireAuth(() => workspacesHandler()),
+ );
}
/**
@@ -173,46 +245,41 @@ export class APIRouter {
// Account tier update
if (path.endsWith("/tier") && method === "POST") {
const tierHandler = createAccountTierUpdateHandler(this.context.dbOps);
- return await this.wrapHandler((req) => tierHandler(req, accountId))(
- req,
- url,
- );
+ return await this.wrapHandler(
+ requireAuth((req) => tierHandler(req, accountId)),
+ )(req, url);
}
// Account pause
if (path.endsWith("/pause") && method === "POST") {
const pauseHandler = createAccountPauseHandler(this.context.dbOps);
- return await this.wrapHandler((req) => pauseHandler(req, accountId))(
- req,
- url,
- );
+ return await this.wrapHandler(
+ requireAuth((req) => pauseHandler(req, accountId)),
+ )(req, url);
}
// Account resume
if (path.endsWith("/resume") && method === "POST") {
const resumeHandler = createAccountResumeHandler(this.context.dbOps);
- return await this.wrapHandler((req) => resumeHandler(req, accountId))(
- req,
- url,
- );
+ return await this.wrapHandler(
+ requireAuth((req) => resumeHandler(req, accountId)),
+ )(req, url);
}
// Account rename
if (path.endsWith("/rename") && method === "POST") {
const renameHandler = createAccountRenameHandler(this.context.dbOps);
- return await this.wrapHandler((req) => renameHandler(req, accountId))(
- req,
- url,
- );
+ return await this.wrapHandler(
+ requireAuth((req) => renameHandler(req, accountId)),
+ )(req, url);
}
// Account removal
if (parts.length === 4 && method === "DELETE") {
const removeHandler = createAccountRemoveHandler(this.context.dbOps);
- return await this.wrapHandler((req) => removeHandler(req, accountId))(
- req,
- url,
- );
+ return await this.wrapHandler(
+ requireAuth((req) => removeHandler(req, accountId)),
+ )(req, url);
}
}
@@ -226,19 +293,17 @@ export class APIRouter {
const preferenceHandler = createAgentPreferenceUpdateHandler(
this.context.dbOps,
);
- return await this.wrapHandler((req) => preferenceHandler(req, agentId))(
- req,
- url,
- );
+ return await this.wrapHandler(
+ requireAuth((req) => preferenceHandler(req, agentId)),
+ )(req, url);
}
// Agent update (PATCH /api/agents/:id)
if (parts.length === 4 && method === "PATCH") {
const updateHandler = createAgentUpdateHandler(this.context.dbOps);
- return await this.wrapHandler((req) => updateHandler(req, agentId))(
- req,
- url,
- );
+ return await this.wrapHandler(
+ requireAuth((req) => updateHandler(req, agentId)),
+ )(req, url);
}
}
diff --git a/packages/load-balancer/src/strategies/index.ts b/packages/load-balancer/src/strategies/index.ts
index 0bb1162f..c356e15f 100644
--- a/packages/load-balancer/src/strategies/index.ts
+++ b/packages/load-balancer/src/strategies/index.ts
@@ -71,24 +71,26 @@ export class SessionStrategy implements LoadBalancingStrategy {
this.log.info(
`Continuing session for account ${activeAccount.name} (${activeAccount.session_request_count} requests in session)`,
);
- // Return active account first, then others as fallback
- const others = accounts.filter(
- (a) => a.id !== activeAccount.id && isAccountAvailable(a, now),
- );
+ // Return active account first, then others as fallback (sorted by priority)
+ const others = accounts
+ .filter((a) => a.id !== activeAccount.id && isAccountAvailable(a, now))
+ .sort((a, b) => (a.priority || 0) - (b.priority || 0));
return [activeAccount, ...others];
}
// No active session or active account is rate limited
- // Filter available accounts
- const available = accounts.filter((a) => isAccountAvailable(a, now));
+ // Filter available accounts and sort by priority (lower values = higher priority)
+ const available = accounts
+ .filter((a) => isAccountAvailable(a, now))
+ .sort((a, b) => (a.priority || 0) - (b.priority || 0));
if (available.length === 0) return [];
- // Pick the first available account and start a new session with it
+ // Pick the first available account (highest priority) and start a new session with it
const chosenAccount = available[0];
this.resetSessionIfExpired(chosenAccount);
- // Return chosen account first, then others as fallback
+ // Return chosen account first, then others as fallback (maintaining priority order)
const others = available.filter((a) => a.id !== chosenAccount.id);
return [chosenAccount, ...others];
}
diff --git a/packages/proxy/src/handlers/proxy-operations.ts b/packages/proxy/src/handlers/proxy-operations.ts
index f1b69440..02893d64 100644
--- a/packages/proxy/src/handlers/proxy-operations.ts
+++ b/packages/proxy/src/handlers/proxy-operations.ts
@@ -59,6 +59,7 @@ export async function proxyUnauthenticated(
retryAttempt: 0,
failoverAttempts: 0,
agentUsed: requestMeta.agentUsed,
+ clientIp: requestMeta.clientIp,
},
ctx,
);
@@ -140,6 +141,7 @@ export async function proxyWithAccount(
retryAttempt: 0,
failoverAttempts,
agentUsed: requestMeta.agentUsed,
+ clientIp: requestMeta.clientIp,
},
ctx,
);
diff --git a/packages/proxy/src/handlers/request-handler.ts b/packages/proxy/src/handlers/request-handler.ts
index ccf80a74..9d1e9d77 100644
--- a/packages/proxy/src/handlers/request-handler.ts
+++ b/packages/proxy/src/handlers/request-handler.ts
@@ -4,6 +4,45 @@ import type { Provider } from "@ccflare/providers";
import type { RequestMeta } from "@ccflare/types";
import { ERROR_MESSAGES } from "./proxy-types";
+/**
+ * Extract client IP from request headers
+ * @param req - The incoming request
+ * @returns Client IP address or null if not available
+ */
+function getClientIp(req: Request): string | null {
+ // Check common headers for real client IP (in order of preference)
+ const headers = req.headers;
+
+ // Check X-Forwarded-For (most common)
+ const xForwardedFor = headers.get("x-forwarded-for");
+ if (xForwardedFor) {
+ // X-Forwarded-For can contain multiple IPs, get the first one (original client)
+ return xForwardedFor.split(",")[0].trim();
+ }
+
+ // Check CF-Connecting-IP (Cloudflare)
+ const cfConnectingIp = headers.get("cf-connecting-ip");
+ if (cfConnectingIp) {
+ return cfConnectingIp;
+ }
+
+ // Check X-Real-IP (nginx)
+ const xRealIp = headers.get("x-real-ip");
+ if (xRealIp) {
+ return xRealIp;
+ }
+
+ // Check X-Client-IP
+ const xClientIp = headers.get("x-client-ip");
+ if (xClientIp) {
+ return xClientIp;
+ }
+
+ // If no proxy headers, we can't determine the real IP from a Request object
+ // The actual socket IP would need to be passed from the server
+ return null;
+}
+
/**
* Creates request metadata for tracking and analytics
* @param req - The incoming request
@@ -16,6 +55,7 @@ export function createRequestMetadata(req: Request, url: URL): RequestMeta {
method: req.method,
path: url.pathname,
timestamp: Date.now(),
+ clientIp: getClientIp(req),
};
}
diff --git a/packages/proxy/src/post-processor.worker.ts b/packages/proxy/src/post-processor.worker.ts
index 5cc16d1e..2ff1e3ee 100644
--- a/packages/proxy/src/post-processor.worker.ts
+++ b/packages/proxy/src/post-processor.worker.ts
@@ -425,6 +425,7 @@ async function handleEnd(msg: EndMessage): Promise {
}
: undefined,
state.agentUsed,
+ startMessage.clientIp || undefined,
),
);
@@ -496,6 +497,7 @@ async function handleEnd(msg: EndMessage): Promise {
costUsd: state.usage.costUsd,
agentUsed: state.agentUsed,
tokensPerSecond: state.usage.tokensPerSecond,
+ clientIp: startMessage.clientIp || undefined,
};
self.postMessage({
diff --git a/packages/proxy/src/response-handler.ts b/packages/proxy/src/response-handler.ts
index e7528c4d..8892d4f8 100644
--- a/packages/proxy/src/response-handler.ts
+++ b/packages/proxy/src/response-handler.ts
@@ -33,6 +33,7 @@ export interface ResponseHandlerOptions {
retryAttempt: number;
failoverAttempts: number;
agentUsed?: string | null;
+ clientIp?: string | null;
}
/**
@@ -56,6 +57,7 @@ export async function forwardToClient(
retryAttempt, // Always 0 in new flow, but kept for message compatibility
failoverAttempts,
agentUsed,
+ clientIp,
} = options;
// Always strip compression headers *before* we do anything else
@@ -86,6 +88,7 @@ export async function forwardToClient(
isStream,
providerName: ctx.provider.name,
agentUsed: agentUsed || null,
+ clientIp: clientIp || null,
retryAttempt,
failoverAttempts,
};
diff --git a/packages/proxy/src/worker-messages.ts b/packages/proxy/src/worker-messages.ts
index 4d732250..45ef34c4 100644
--- a/packages/proxy/src/worker-messages.ts
+++ b/packages/proxy/src/worker-messages.ts
@@ -26,6 +26,9 @@ export interface StartMessage {
// Agent info
agentUsed: string | null;
+ // Client IP
+ clientIp: string | null;
+
// Retry info
retryAttempt: number;
failoverAttempts: number;
diff --git a/packages/tui-core/src/requests.ts b/packages/tui-core/src/requests.ts
index 9af1c13b..b7a688ac 100644
--- a/packages/tui-core/src/requests.ts
+++ b/packages/tui-core/src/requests.ts
@@ -13,6 +13,7 @@ export interface RequestSummary {
cacheCreationInputTokens?: number;
costUsd?: number;
responseTimeMs?: number;
+ clientIp?: string;
}
export async function getRequests(limit = 100): Promise {
@@ -61,7 +62,8 @@ export async function getRequestSummaries(
cache_read_input_tokens as cacheReadInputTokens,
cache_creation_input_tokens as cacheCreationInputTokens,
cost_usd as costUsd,
- response_time_ms as responseTimeMs
+ response_time_ms as responseTimeMs,
+ client_ip as clientIp
FROM requests
ORDER BY timestamp DESC
LIMIT ?
@@ -76,6 +78,7 @@ export async function getRequestSummaries(
cacheCreationInputTokens?: number;
costUsd?: number;
responseTimeMs?: number;
+ clientIp?: string;
}>;
const summaryMap = new Map();
@@ -90,6 +93,7 @@ export async function getRequestSummaries(
cacheCreationInputTokens: summary.cacheCreationInputTokens || undefined,
costUsd: summary.costUsd || undefined,
responseTimeMs: summary.responseTimeMs || undefined,
+ clientIp: summary.clientIp || undefined,
});
});
diff --git a/packages/types/src/account.ts b/packages/types/src/account.ts
index 1f2dbc29..ae7bf0df 100644
--- a/packages/types/src/account.ts
+++ b/packages/types/src/account.ts
@@ -22,6 +22,7 @@ export interface AccountRow {
rate_limit_reset?: number | null;
rate_limit_status?: string | null;
rate_limit_remaining?: number | null;
+ priority?: number;
}
// Domain model - used throughout the application
@@ -45,6 +46,7 @@ export interface Account {
rate_limit_reset: number | null;
rate_limit_status: string | null;
rate_limit_remaining: number | null;
+ priority: number; // Lower values = higher priority (0 is highest)
}
// API response type - what clients receive
@@ -64,6 +66,7 @@ export interface AccountResponse {
rateLimitReset: string | null;
rateLimitRemaining: number | null;
sessionInfo: string;
+ priority: number;
}
// UI display type - used in TUI and web dashboard
@@ -86,6 +89,7 @@ export interface AccountDisplay {
session_start?: number | null;
session_request_count?: number;
access_token?: string | null;
+ priority: number;
}
// CLI list item type
@@ -104,6 +108,7 @@ export interface AccountListItem {
rateLimitStatus: string;
sessionInfo: string;
mode: "max" | "console";
+ priority: number;
}
// Account creation types
@@ -139,6 +144,7 @@ export function toAccount(row: AccountRow): Account {
rate_limit_reset: row.rate_limit_reset || null,
rate_limit_status: row.rate_limit_status || null,
rate_limit_remaining: row.rate_limit_remaining || null,
+ priority: row.priority || 0,
};
}
@@ -177,6 +183,7 @@ export function toAccountResponse(account: Account): AccountResponse {
: null,
rateLimitRemaining: account.rate_limit_remaining,
sessionInfo,
+ priority: account.priority,
};
}
@@ -212,5 +219,6 @@ export function toAccountDisplay(account: Account): AccountDisplay {
session_start: account.session_start,
session_request_count: account.session_request_count,
access_token: account.access_token,
+ priority: account.priority,
};
}
diff --git a/packages/types/src/api.ts b/packages/types/src/api.ts
index cbadd560..503582c5 100644
--- a/packages/types/src/api.ts
+++ b/packages/types/src/api.ts
@@ -4,6 +4,7 @@ export interface RequestMeta {
path: string;
timestamp: number;
agentUsed?: string | null;
+ clientIp?: string | null;
}
export interface AgentUpdatePayload {
diff --git a/packages/types/src/request.ts b/packages/types/src/request.ts
index 99bc8b6f..18e40982 100644
--- a/packages/types/src/request.ts
+++ b/packages/types/src/request.ts
@@ -21,6 +21,7 @@ export interface RequestRow {
output_tokens: number | null;
agent_used: string | null;
output_tokens_per_second: number | null;
+ client_ip: string | null;
}
// Domain model
@@ -46,6 +47,7 @@ export interface Request {
outputTokens?: number;
agentUsed?: string;
tokensPerSecond?: number;
+ clientIp?: string;
}
// API response type
@@ -71,6 +73,7 @@ export interface RequestResponse {
costUsd?: number;
agentUsed?: string;
tokensPerSecond?: number;
+ clientIp?: string;
}
// Detailed request with payload
@@ -125,6 +128,7 @@ export function toRequest(row: RequestRow): Request {
outputTokens: row.output_tokens || undefined,
agentUsed: row.agent_used || undefined,
tokensPerSecond: row.output_tokens_per_second || undefined,
+ clientIp: row.client_ip || undefined,
};
}
@@ -151,6 +155,7 @@ export function toRequestResponse(request: Request): RequestResponse {
costUsd: request.costUsd,
agentUsed: request.agentUsed,
tokensPerSecond: request.tokensPerSecond,
+ clientIp: request.clientIp,
};
}