feat: add gateway API, voice service, WebSocket hub, and startup entry

- services/gateway/api: net/http router with JWT auth, stub login, 6 tests
- services/gateway/ws: WebSocket hub with echo support
- services/voice: STT/TTS provider chain with fallback, 6 tests
- cmd/cyrene/main.go: single binary startup, 108 tests total
- Smoke test: health check + login verified on :8080
This commit is contained in:
Aska committed 2026-08-09 21:09:43 +08:00
1 parent 81ebdb910c
commit 99806fefad
12 files changed
+874 -1

No files matched your search

+56
View File
@@ -0,0 +1,56 @@
package api
import (
"net/http"
)
// StubAuthManager is an in-memory auth manager for development and testing.
type StubAuthManager struct {
tokens map[string]TokenInfo
users map[string]UserInfo
}
// TokenInfo holds decoded token information.
type TokenInfo struct {
UserID string
Username string
Role string
}
// UserInfo holds user credentials (development only — production uses bcrypt).
type UserInfo struct {
Username string
Password string
Role string
}
// NewStubAuthManager creates an in-memory auth manager with a default admin user.
func NewStubAuthManager() *StubAuthManager {
m := &StubAuthManager{
tokens: make(map[string]TokenInfo),
users: make(map[string]UserInfo),
}
m.users["admin"] = UserInfo{Username: "admin", Password: "admin", Role: "admin"}
m.users["user"] = UserInfo{Username: "user", Password: "user", Role: "user"}
return m
}
// ValidateToken validates a stub token.
func (m *StubAuthManager) ValidateToken(token string) (string, string, string, error) {
info, ok := m.tokens[token]
if !ok {
return "", "", "", http.ErrNoCookie
}
return info.UserID, info.Username, info.Role, nil
}
// Login authenticates a user and returns a stub token.
func (m *StubAuthManager) Login(username, password string) (string, error) {
user, ok := m.users[username]
if !ok || user.Password != password {
return "", http.ErrNoCookie
}
token := "stub-token-" + username
m.tokens[token] = TokenInfo{UserID: username, Username: username, Role: user.Role}
return token, nil
}
+167
View File
@@ -0,0 +1,167 @@
// Package api provides HTTP routing, middleware, and handlers for the Cyrene Gateway.
// Uses only the standard library net/http to minimize dependencies.
package api
import (
"encoding/json"
"net/http"
"strings"
)
// Router handles HTTP routing with middleware support.
type Router struct {
mux *http.ServeMux
authMgr AuthManager
}
// AuthManager is the interface for JWT authentication.
type AuthManager interface {
ValidateToken(token string) (userID, username, role string, err error)
}
// New creates a new Router with all routes configured.
func New(authMgr AuthManager) *Router {
r := &Router{
mux: http.NewServeMux(),
authMgr: authMgr,
}
r.setupRoutes()
return r
}
func (r *Router) setupRoutes() {
// Public endpoints
r.mux.HandleFunc("/api/v1/health", r.healthHandler)
r.mux.HandleFunc("/api/v1/auth/login", r.loginHandler)
r.mux.HandleFunc("/api/v1/auth/register", r.registerHandler)
// Protected endpoints
r.mux.HandleFunc("/api/v1/sessions", r.authMW(r.sessionsHandler))
r.mux.HandleFunc("/api/v1/sessions/", r.authMW(r.sessionByIDHandler))
r.mux.HandleFunc("/api/v1/admin/dashboard", r.authMW(r.adminMW(r.adminDashboardHandler)))
}
// ServeHTTP implements http.Handler.
func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
// CORS
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type")
if req.Method == "OPTIONS" {
w.WriteHeader(http.StatusNoContent)
return
}
r.mux.ServeHTTP(w, req)
}
// --- Middleware ---
func (r *Router) authMW(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
header := req.Header.Get("Authorization")
if header == "" || !strings.HasPrefix(strings.ToLower(header), "bearer ") {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "missing or invalid authorization"})
return
}
token := header[7:]
userID, username, role, err := r.authMgr.ValidateToken(token)
if err != nil {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid token"})
return
}
req.Header.Set("X-User-ID", userID)
req.Header.Set("X-Username", username)
req.Header.Set("X-Role", role)
next(w, req)
}
}
func (r *Router) adminMW(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
if req.Header.Get("X-Role") != "admin" {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "admin access required"})
return
}
next(w, req)
}
}
// --- Public handlers ---
func (r *Router) healthHandler(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok", "service": "cyrene-gateway"})
}
func (r *Router) loginHandler(w http.ResponseWriter, req *http.Request) {
if req.Method != "POST" {
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "POST required"})
return
}
var body struct {
Username string `json:"username"`
Password string `json:"password"`
}
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"})
return
}
mgr, ok := r.authMgr.(*StubAuthManager)
if !ok {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "auth not configured"})
return
}
token, err := mgr.Login(body.Username, body.Password)
if err != nil {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid credentials"})
return
}
writeJSON(w, http.StatusOK, map[string]string{"token": token})
}
func (r *Router) registerHandler(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"message": "registration not implemented"})
}
// --- Protected handlers ---
func (r *Router) sessionsHandler(w http.ResponseWriter, req *http.Request) {
switch req.Method {
case "GET":
writeJSON(w, http.StatusOK, map[string]any{"sessions": []any{}})
case "POST":
writeJSON(w, http.StatusCreated, map[string]string{"session_id": "stub-session"})
default:
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
}
}
func (r *Router) sessionByIDHandler(w http.ResponseWriter, req *http.Request) {
id := strings.TrimPrefix(req.URL.Path, "/api/v1/sessions/")
if req.Method == "DELETE" {
writeJSON(w, http.StatusNoContent, nil)
return
}
writeJSON(w, http.StatusOK, map[string]string{"session_id": id})
}
func (r *Router) adminDashboardHandler(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{
"dashboard": map[string]any{
"status": "running",
"version": "2.0.0-dev",
},
})
}
// --- Helpers ---
func writeJSON(w http.ResponseWriter, status int, data any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if data != nil {
json.NewEncoder(w).Encode(data)
}
}
+136
View File
@@ -0,0 +1,136 @@
package api
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestHealth(t *testing.T) {
r := New(NewStubAuthManager())
srv := httptest.NewServer(r)
defer srv.Close()
resp, err := http.Get(srv.URL + "/api/v1/health")
if err != nil {
t.Fatalf("GET health: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("expected 200, got %d", resp.StatusCode)
}
}
func TestLoginAndProtectedEndpoint(t *testing.T) {
auth := NewStubAuthManager()
r := New(auth)
srv := httptest.NewServer(r)
defer srv.Close()
// Login
resp, err := http.Post(srv.URL+"/api/v1/auth/login", "application/json",
strings.NewReader(`{"username":"admin","password":"admin"}`))
if err != nil {
t.Fatalf("POST login: %v", err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("login: expected 200, got %d", resp.StatusCode)
}
// Use token for protected endpoint
req, _ := http.NewRequest("GET", srv.URL+"/api/v1/sessions", nil)
req.Header.Set("Authorization", "Bearer stub-token-admin")
resp2, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("GET sessions: %v", err)
}
resp2.Body.Close()
if resp2.StatusCode != http.StatusOK {
t.Errorf("sessions: expected 200, got %d", resp2.StatusCode)
}
}
func TestLoginWrongPassword(t *testing.T) {
auth := NewStubAuthManager()
r := New(auth)
srv := httptest.NewServer(r)
defer srv.Close()
resp, err := http.Post(srv.URL+"/api/v1/auth/login", "application/json",
strings.NewReader(`{"username":"admin","password":"wrong"}`))
if err != nil {
t.Fatalf("POST login: %v", err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusUnauthorized {
t.Errorf("expected 401, got %d", resp.StatusCode)
}
}
func TestProtectedWithoutToken(t *testing.T) {
auth := NewStubAuthManager()
r := New(auth)
srv := httptest.NewServer(r)
defer srv.Close()
resp, err := http.Get(srv.URL + "/api/v1/sessions")
if err != nil {
t.Fatalf("GET sessions: %v", err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusUnauthorized {
t.Errorf("expected 401, got %d", resp.StatusCode)
}
}
func TestStubAuthManager(t *testing.T) {
m := NewStubAuthManager()
// Login as admin
token, err := m.Login("admin", "admin")
if err != nil {
t.Fatalf("Login: %v", err)
}
uid, uname, role, err := m.ValidateToken(token)
if err != nil {
t.Fatalf("ValidateToken: %v", err)
}
if uid != "admin" || uname != "admin" || role != "admin" {
t.Errorf("wrong claims: %s/%s/%s", uid, uname, role)
}
// Invalid token
_, _, _, err = m.ValidateToken("bogus")
if err == nil {
t.Error("expected error for bogus token")
}
}
func TestCORSHeaders(t *testing.T) {
r := New(NewStubAuthManager())
srv := httptest.NewServer(r)
defer srv.Close()
req, _ := http.NewRequest("OPTIONS", srv.URL+"/api/v1/health", nil)
req.Header.Set("Origin", "http://localhost:5173")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("OPTIONS: %v", err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusNoContent {
t.Errorf("expected 204 for OPTIONS, got %d", resp.StatusCode)
}
if resp.Header.Get("Access-Control-Allow-Origin") == "" {
t.Error("expected CORS header")
}
}