// Package api provides HTTP routing and handlers for the persona-api service. package api import ( "time" emailpkg "git.threesix.ai/jordan/persona-community-2/pkg/email" "git.threesix.ai/jordan/persona-community-2/pkg/app" "git.threesix.ai/jordan/persona-community-2/pkg/auth" "git.threesix.ai/jordan/persona-community-2/pkg/middleware" "git.threesix.ai/jordan/persona-community-2/pkg/queue" "git.threesix.ai/jordan/persona-community-2/pkg/realtime" "git.threesix.ai/jordan/persona-community-2/pkg/storage" "git.threesix.ai/jordan/persona-community-2/services/persona-api/internal/api/handlers" "git.threesix.ai/jordan/persona-community-2/services/persona-api/internal/config" "git.threesix.ai/jordan/persona-community-2/services/persona-api/internal/port" "git.threesix.ai/jordan/persona-community-2/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-2", }) // 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: 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 }