persona-community-1/services/persona-api/internal/api/routes.go
2026-02-23 10:21:29 +00:00

185 lines
7.6 KiB
Go

// Package api provides HTTP routing and handlers for the persona-api service.
package api
import (
"time"
emailpkg "git.threesix.ai/jordan/persona-community-1/pkg/email"
"git.threesix.ai/jordan/persona-community-1/pkg/app"
"git.threesix.ai/jordan/persona-community-1/pkg/auth"
"git.threesix.ai/jordan/persona-community-1/pkg/middleware"
"git.threesix.ai/jordan/persona-community-1/pkg/queue"
"git.threesix.ai/jordan/persona-community-1/pkg/realtime"
"git.threesix.ai/jordan/persona-community-1/pkg/storage"
"git.threesix.ai/jordan/persona-community-1/services/persona-api/internal/api/handlers"
"git.threesix.ai/jordan/persona-community-1/services/persona-api/internal/config"
"git.threesix.ai/jordan/persona-community-1/services/persona-api/internal/port"
"git.threesix.ai/jordan/persona-community-1/services/persona-api/internal/service"
)
// RegisterRoutes registers all HTTP routes for the service.
// Routes are mounted under /api/persona-api to match the ingress path routing.
// This allows the monorepo to expose multiple services under a single domain:
// - https://domain/api/persona-api/health
// - https://domain/api/persona-api/examples
// - https://domain/api/persona-api/events?channel=user:123 (SSE)
func RegisterRoutes(application *app.App, deps *Dependencies) {
logger := application.Logger()
cfg := config.Load()
// Initialize handlers with injected services
healthHandler := handlers.NewHealth(logger)
exampleHandler := handlers.NewExample(deps.ExampleService, logger)
authHandler := handlers.NewAuth(deps.AuthService, logger)
generateHandler := handlers.NewGenerate(deps.Queue, deps.JobReader, deps.SSEHub, logger)
chatHandler := handlers.NewChat(deps.Queue, deps.SSEHub, logger)
mediaHandler := handlers.NewMedia(deps.Store, deps.MediaRepo, logger)
albumHandler := handlers.NewAlbum(deps.AlbumService, logger)
personaHandler := handlers.NewPersona(deps.Queue, deps.JobReader, logger)
// Build and mount OpenAPI spec
spec := NewServiceSpec()
application.EnableDocs(spec)
// JWT validator for protected routes
jwtValidator := auth.NewJWTValidator(auth.JWTConfig{
Secret: []byte(cfg.JWTSecret),
Issuer: "persona-community-1",
})
// Dev email preview (development only — not mounted in production).
if cfg.AppConfig.Environment == "development" && deps.EmailRenderer != nil {
devHandler := emailpkg.NewDevHandler(deps.EmailRenderer)
application.Router().Get("/dev/emails", devHandler.List)
application.Router().Get("/dev/emails/{purpose}", devHandler.Preview)
}
// Register API routes under /api/{service-name} to match ingress path routing.
// The ingress routes /api/persona-api/* to this service.
application.Route("/api/persona-api", func(r app.Router) {
r.Get("/health", healthHandler.Check)
// ----- Public auth routes (rate-limited) -----
// Auth attempts: 20/min per IP (login, register, verify, reset).
authAttemptLimit := middleware.RateLimit(middleware.RateLimitConfig{Requests: 20, Window: time.Minute})
// Code sends: 5/min per IP (prevents email bombing via OTP/magic-link/forgot-password).
codeSendLimit := middleware.RateLimit(middleware.RateLimitConfig{Requests: 5, Window: time.Minute})
r.Group(func(r app.Router) {
r.Use(authAttemptLimit)
r.Post("/auth/login", app.Wrap(authHandler.Login))
r.Post("/auth/register", app.Wrap(authHandler.Register))
r.Post("/auth/otp/verify", app.Wrap(authHandler.VerifyOTP))
r.Post("/auth/magic-link/verify", app.Wrap(authHandler.VerifyMagicLink))
r.Post("/auth/reset-password", app.Wrap(authHandler.ResetPassword))
})
r.Group(func(r app.Router) {
r.Use(codeSendLimit)
r.Post("/auth/otp/send", app.Wrap(authHandler.SendOTP))
r.Post("/auth/magic-link", app.Wrap(authHandler.SendMagicLink))
r.Post("/auth/forgot-password", app.Wrap(authHandler.ForgotPassword))
})
// Refresh accepts expired tokens (still validates signature).
// The service layer checks session validity to prevent abuse.
r.Group(func(r app.Router) {
r.Use(auth.Middleware(auth.MiddlewareConfig{
Validator: jwtValidator,
AllowExpired: true,
}))
r.Post("/auth/refresh", app.Wrap(authHandler.RefreshToken))
})
// Session checker for revocation enforcement.
sessionChecker := deps.AuthService.CheckSession
// ----- Protected auth routes -----
r.Group(func(r app.Router) {
r.Use(auth.Middleware(auth.MiddlewareConfig{
Validator: jwtValidator,
}))
r.Use(auth.SessionCheck(sessionChecker))
r.Get("/auth/me", app.Wrap(authHandler.Me))
r.Put("/auth/me", app.Wrap(authHandler.UpdateMe))
r.Post("/auth/change-password", app.Wrap(authHandler.ChangePassword))
r.Post("/auth/logout", app.Wrap(authHandler.Logout))
r.Post("/auth/verify-email/send", app.Wrap(authHandler.SendVerifyEmail))
r.Post("/auth/verify-email", app.Wrap(authHandler.VerifyEmail))
r.Get("/auth/sessions", app.Wrap(authHandler.ListSessions))
r.Delete("/auth/sessions", app.Wrap(authHandler.RevokeAllSessions))
r.Delete("/auth/sessions/{id}", app.Wrap(authHandler.RevokeSession))
})
// ----- SSE Events -----
// Server-Sent Events for async job updates (generation progress, etc.)
r.Mount("/events", generateHandler.Events())
// ----- Example routes -----
// Public routes (no auth required)
r.Get("/examples", app.Wrap(exampleHandler.List))
r.Get("/examples/{id}", app.Wrap(exampleHandler.Get))
// Protected routes (auth required when enabled)
r.Group(func(r app.Router) {
if cfg.AuthEnabled {
r.Use(auth.Middleware(auth.MiddlewareConfig{
Validator: jwtValidator,
}))
}
r.Post("/examples", app.Wrap(exampleHandler.Create))
r.Put("/examples/{id}", app.Wrap(exampleHandler.Update))
r.Delete("/examples/{id}", app.Wrap(exampleHandler.Delete))
})
// ----- Chat + Generate + Media routes (auth required) -----
// Auth is required because SSE events are delivered to user:<userId> channels.
// Without a real user identity, events go to user:anonymous and never reach the client.
r.Group(func(r app.Router) {
r.Use(auth.Middleware(auth.MiddlewareConfig{
Validator: jwtValidator,
}))
r.Use(auth.SessionCheck(sessionChecker))
// Chat messaging
r.Post("/chat/messages", app.Wrap(chatHandler.SendMessage))
// Media generation (all queue-based, returns 202)
r.Post("/generate/image", app.Wrap(generateHandler.GenerateImage))
r.Post("/generate/video", app.Wrap(generateHandler.GenerateVideo))
r.Post("/generate/text", app.Wrap(generateHandler.GenerateText))
r.Get("/generate/jobs/{id}", app.Wrap(generateHandler.GetJobStatus))
// Media library (upload, list, delete)
r.Mount("/media", mediaHandler.Routes())
// Album generation (anchor + shots)
r.Get("/albums", app.Wrap(albumHandler.List))
r.Post("/albums", app.Wrap(albumHandler.Create))
r.Get("/albums/{id}", app.Wrap(albumHandler.Get))
r.Delete("/albums/{id}", app.Wrap(albumHandler.Delete))
r.Post("/albums/{id}/anchor", app.Wrap(albumHandler.GenerateAnchor))
r.Post("/albums/{id}/shots", app.Wrap(albumHandler.GenerateAllShots))
r.Post("/albums/{id}/shots/{index}", app.Wrap(albumHandler.GenerateShot))
r.Delete("/albums/{id}/shots/{index}", app.Wrap(albumHandler.ResetShot))
// Persona generation (5-stage LLM + 20 images + 4 videos, all async)
r.Post("/persona/generate", app.Wrap(personaHandler.GeneratePersona))
})
})
}
// Dependencies holds all service dependencies for route registration.
type Dependencies struct {
ExampleService *service.ExampleService
AuthService *service.AuthService
AlbumService *service.AlbumService
Queue queue.Producer
JobReader queue.JobReader
SSEHub *realtime.SSEHub
Store storage.Store
MediaRepo port.MediaRepository
EmailRenderer *emailpkg.Renderer
}