Skip to content

Commit 2852ef9

Browse files
committed
Release v0.7.0: Authentication, WebSocket real-time, frontend fixes
Backend: - Add CORS support for dashboard (localhost:3000) - Hybrid auth on all API routes (session OR bearer) - Secure cookies auto-configured based on TLS - Fix prefixdctl operators create column name Frontend: - Fix API response unwrapping (mitigations/events wrappers) - Fix Stats/HealthResponse/PopInfo types to match backend - Fix SWR cache invalidation keys in WebSocket hook - Fix status filter query param (comma-separated) Docs: - Update CHANGELOG with v0.7.0 release notes - Update ROADMAP with v0.7 section
1 parent 42267bf commit 2852ef9

8 files changed

Lines changed: 95 additions & 26 deletions

File tree

CHANGELOG.md

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ All notable changes to prefixd will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8-
## [Unreleased]
8+
## [0.7.0] - 2026-01-18
99

1010
### Added
1111

@@ -46,6 +46,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
4646
- **Security**
4747
- Request body size limit (1MB) via tower-http
4848
- Fix SQL injection in `list_mitigations` queries (now uses parameterized queries)
49+
- Hybrid auth on all API routes (session OR bearer token)
50+
- Secure cookies configurable based on TLS presence
51+
52+
- **CORS Support**
53+
- CORS headers for dashboard cross-origin requests
54+
- Credentials support for session cookies
4955

5056
- **Reliability**
5157
- GoBGP gRPC timeout handling (10s connect, 30s request)
@@ -85,6 +91,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
8591

8692
### Fixed
8793

94+
- **Frontend API Integration**
95+
- Fix API response unwrapping (mitigations/events return `{items, count}` wrapper)
96+
- Fix Stats type to match backend (`total_active` instead of `active_mitigations`)
97+
- Fix HealthResponse type to match backend (structured `bgp_sessions`, `gobgp` object)
98+
- Fix PopInfo type (`{pop, active_mitigations}` objects instead of strings)
99+
- Fix SWR cache invalidation keys in WebSocket hook
100+
- Fix status filter query param (comma-separated instead of repeated)
101+
88102
- **Startup validation** - `auth.mode=bearer` without token now fails fast at startup (was returning 500 on every request)
89103

90104
- IPv6 prefix validation uses proper `IpAddr` parsing (was using contains(':') heuristic)

ROADMAP.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,35 @@
156156
- [x] Config page (system status, BGP, quotas, safelist viewer)
157157
- [x] Loading/error states throughout
158158

159+
## v0.7 - Authentication & Real-Time Dashboard (Done)
160+
161+
**Goal:** Session-based auth for dashboard, WebSocket real-time updates, production-ready frontend.
162+
163+
### Authentication (Done)
164+
- [x] Operators table with argon2 password hashing
165+
- [x] PostgreSQL session store via tower-sessions
166+
- [x] Login/logout/me endpoints
167+
- [x] Hybrid auth model (session for browser, bearer for CLI)
168+
- [x] `prefixdctl operators create` command
169+
- [x] Secure cookies configurable (auto-detect TLS)
170+
- [x] CORS support for dashboard
171+
172+
### WebSocket Real-Time (Done)
173+
- [x] WebSocket endpoint `/v1/ws/feed`
174+
- [x] Message types: MitigationCreated/Updated/Expired/Withdrawn, EventIngested, ResyncRequired
175+
- [x] Broadcast channel in handlers and reconciliation loop
176+
- [x] Lag detection with ResyncRequired
177+
178+
### Frontend Auth & Real-Time (Done)
179+
- [x] Login page with form validation
180+
- [x] `useAuth` hook with AuthProvider
181+
- [x] `useWebSocket` hook with reconnection
182+
- [x] `RequireAuth` protected route wrapper
183+
- [x] ConnectionStatus indicator
184+
- [x] UserMenu with logout
185+
- [x] SWR cache invalidation on WS messages
186+
- [x] Fix API response type mismatches
187+
159188
## v1.1 - Integration Tests & Bug Fixes
160189

161190
**Goal:** Comprehensive integration test coverage and critical bug fixes.

docker-compose.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ services:
2626
ports:
2727
- "3000:3000"
2828
environment:
29-
- NEXT_PUBLIC_PREFIXD_API=http://prefixd:8080
29+
- NEXT_PUBLIC_PREFIXD_API=http://localhost:8080
3030
- NEXT_PUBLIC_PREFIXD_TOKEN=${PREFIXD_API_TOKEN:-}
3131
depends_on:
3232
- prefixd

frontend/app/page.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,16 +27,16 @@ export default function OverviewPage() {
2727
<BgpSessionStatus />
2828

2929
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
30-
<StatCard title="Active Mitigations" value={stats?.active_mitigations ?? activeMitigations.length} />
30+
<StatCard title="Active Mitigations" value={stats?.total_active ?? activeMitigations.length} />
3131
<StatCard title="Police Actions" value={policeActions.length} accent="primary" />
3232
<StatCard title="Discard Actions" value={discardActions.length} accent="destructive" />
33-
<StatCard title="Events (24h)" value={stats?.events_24h ?? 0} />
33+
<StatCard title="Total Events" value={stats?.total_events ?? 0} />
3434
</div>
3535

3636
<div className="grid grid-cols-1 lg:grid-cols-3 gap-3">
3737
<QuotaGauge
3838
title="Global Quota"
39-
current={stats?.active_mitigations ?? 0}
39+
current={stats?.total_active ?? 0}
4040
max={500}
4141
secondary={{
4242
title: "Total Events",

frontend/hooks/use-websocket.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -100,17 +100,19 @@ export function useWebSocket(options: UseWebSocketOptions = {}) {
100100
}
101101

102102
// Invalidate relevant caches based on message type
103+
// SWR keys match hook keys: "mitigations", "stats", "events"
103104
if (message.type === "MitigationCreated" ||
104105
message.type === "MitigationUpdated" ||
105106
message.type === "MitigationExpired" ||
106107
message.type === "MitigationWithdrawn") {
107-
mutate("/v1/mitigations")
108-
mutate("/v1/stats")
108+
// Invalidate all mitigation-related keys (including those with params)
109+
mutate((key) => typeof key === "string" && key.startsWith("mitigations") || Array.isArray(key) && key[0] === "mitigations")
110+
mutate("stats")
109111
}
110112

111113
if (message.type === "EventIngested") {
112-
mutate("/v1/events")
113-
mutate("/v1/stats")
114+
mutate((key) => typeof key === "string" && key.startsWith("events") || Array.isArray(key) && key[0] === "events")
115+
mutate("stats")
114116
}
115117
} catch (e) {
116118
console.error("Failed to parse WebSocket message:", e)

frontend/lib/api.ts

Lines changed: 30 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -48,26 +48,35 @@ export interface Event {
4848
}
4949

5050
export interface Stats {
51+
total_active: number
5152
total_mitigations: number
52-
active_mitigations: number
53-
events_24h: number
5453
total_events: number
55-
pops: string[]
54+
pops: PopInfo[]
55+
}
56+
57+
export interface PopInfo {
58+
pop: string
59+
active_mitigations: number
60+
last_seen: string
5661
}
5762

5863
export interface HealthResponse {
5964
status: string
60-
version: string
61-
pop: string
62-
uptime_seconds: number
63-
bgp_session_up: boolean
65+
bgp_sessions: Record<string, string>
66+
active_mitigations: number
67+
database: string
68+
gobgp: {
69+
status: string
70+
error?: string
71+
}
6472
}
6573

6674
export interface SafelistEntry {
6775
prefix: string
68-
reason: string
76+
reason: string | null
6977
added_by: string
7078
added_at: string
79+
expires_at: string | null
7180
}
7281

7382
async function fetchApi<T>(endpoint: string, options?: RequestInit): Promise<T> {
@@ -102,7 +111,10 @@ async function fetchApi<T>(endpoint: string, options?: RequestInit): Promise<T>
102111
}
103112

104113
async function doFetch<T>(url: string, options: RequestInit): Promise<T> {
105-
const res = await fetch(url, options)
114+
const res = await fetch(url, {
115+
...options,
116+
credentials: "include", // Send session cookies for hybrid auth
117+
})
106118

107119
if (!res.ok) {
108120
const error = await res.text()
@@ -128,16 +140,18 @@ export async function getMitigations(params?: {
128140
offset?: number
129141
}): Promise<Mitigation[]> {
130142
const searchParams = new URLSearchParams()
131-
if (params?.status) {
132-
params.status.forEach((s) => searchParams.append("status", s))
143+
if (params?.status && params.status.length > 0) {
144+
// Backend expects comma-separated status values
145+
searchParams.set("status", params.status.join(","))
133146
}
134147
if (params?.customer_id) searchParams.set("customer_id", params.customer_id)
135148
if (params?.pop) searchParams.set("pop", params.pop)
136149
if (params?.limit) searchParams.set("limit", params.limit.toString())
137150
if (params?.offset) searchParams.set("offset", params.offset.toString())
138151

139152
const query = searchParams.toString()
140-
return fetchApi<Mitigation[]>(`/v1/mitigations${query ? `?${query}` : ""}`)
153+
const response = await fetchApi<{ mitigations: Mitigation[]; count: number }>(`/v1/mitigations${query ? `?${query}` : ""}`)
154+
return response.mitigations
141155
}
142156

143157
export async function getMitigation(id: string): Promise<Mitigation> {
@@ -164,7 +178,8 @@ export async function getEvents(params?: {
164178
if (params?.offset) searchParams.set("offset", params.offset.toString())
165179

166180
const query = searchParams.toString()
167-
return fetchApi<Event[]>(`/v1/events${query ? `?${query}` : ""}`)
181+
const response = await fetchApi<{ events: Event[]; count: number }>(`/v1/events${query ? `?${query}` : ""}`)
182+
return response.events
168183
}
169184

170185
export interface AuditEntry {
@@ -212,8 +227,8 @@ export async function removeSafelist(prefix: string): Promise<void> {
212227
})
213228
}
214229

215-
export async function getPops(): Promise<string[]> {
216-
return fetchApi<string[]>("/v1/pops")
230+
export async function getPops(): Promise<PopInfo[]> {
231+
return fetchApi<PopInfo[]>("/v1/pops")
217232
}
218233

219234
export async function reloadConfig(): Promise<void> {

src/api/routes.rs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
use axum::{
2-
http::{header, HeaderValue},
2+
http::{header, HeaderValue, Method},
33
response::IntoResponse,
44
routing::{any, get, post},
55
Json, Router,
66
};
77
use axum_login::AuthManagerLayer;
88
use std::sync::Arc;
9+
use tower_http::cors::CorsLayer;
910
use tower_http::limit::RequestBodyLimitLayer;
1011
use tower_http::set_header::SetResponseHeaderLayer;
1112
use tower_sessions_sqlx_store::PostgresStore;
@@ -72,6 +73,14 @@ pub fn create_router(
7273
))
7374
// Request body size limit (1MB)
7475
.layer(RequestBodyLimitLayer::new(1024 * 1024))
76+
// CORS for dashboard
77+
.layer(
78+
CorsLayer::new()
79+
.allow_origin("http://localhost:3000".parse::<HeaderValue>().unwrap())
80+
.allow_methods([Method::GET, Method::POST, Method::DELETE, Method::OPTIONS])
81+
.allow_headers([header::CONTENT_TYPE, header::AUTHORIZATION, header::COOKIE])
82+
.allow_credentials(true),
83+
)
7584
}
7685

7786
async fn openapi_json() -> impl IntoResponse {

src/bin/prefixdctl.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -606,7 +606,7 @@ async fn cmd_operators(cmd: OperatorCommands, format: OutputFormat) -> Result<()
606606
r#"
607607
INSERT INTO operators (username, password_hash, role)
608608
VALUES ($1, $2, $3)
609-
RETURNING id
609+
RETURNING operator_id
610610
"#
611611
)
612612
.bind(&username)

0 commit comments

Comments
 (0)