package api import ( "context" "io/fs" "net/http" "os" "github.com/go-chi/chi/v5" "github.com/mark3labs/mcp-go/server" "github.com/mish/dealspace/lib" ) // NewRouter creates the main router with all routes registered. func NewRouter(db *lib.DB, cfg *lib.Config, store lib.ObjectStore, websiteFS fs.FS, portalFS fs.FS, mcpServer *server.MCPServer) *chi.Mux { r := chi.NewRouter() h := NewHandlers(db, cfg, store) oauth := NewOAuthHandlers(db, cfg) // Global middleware r.Use(LoggingMiddleware) r.Use(CORSMiddleware) r.Use(SecurityHeadersMiddleware) r.Use(BlockDatabaseMiddleware) // HARD RULE: no raw DB files served under any circumstance r.Use(RateLimitMiddleware(120)) // 120 req/min per IP // Health check (unauthenticated) r.Get("/health", h.Health) // Chat endpoint (unauthenticated, for Aria chatbot) r.Post("/api/chat", h.ChatHandler) r.Options("/api/chat", h.ChatHandler) // Auth endpoints (unauthenticated — email challenge OTP flow) r.Post("/api/auth/challenge", h.Challenge) r.Post("/api/auth/verify", h.Verify) // Auth endpoints (need token for logout/me) r.Group(func(r chi.Router) { r.Use(AuthMiddleware(db, cfg.JWTSecret)) r.Post("/api/auth/logout", h.Logout) r.Get("/api/auth/me", h.Me) }) // API routes (authenticated) r.Route("/api", func(r chi.Router) { r.Use(AuthMiddleware(db, cfg.JWTSecret)) // Tasks (cross-project) r.Get("/tasks", h.GetAllTasks) // Projects r.Get("/projects", h.GetAllProjects) r.Post("/projects", h.CreateProject) r.Get("/projects/{projectID}", h.GetProjectDetail) r.Get("/projects/{projectID}/detail", h.GetProjectDetail) // legacy alias // Workstreams r.Post("/projects/{projectID}/workstreams", h.CreateWorkstream) // Entries r.Get("/projects/{projectID}/entries", h.ListEntries) r.Post("/projects/{projectID}/entries", h.CreateEntry) r.Put("/projects/{projectID}/entries/{entryID}", h.UpdateEntry) r.Delete("/projects/{projectID}/entries/{entryID}", h.DeleteEntry) // Task inbox (per-project) r.Get("/projects/{projectID}/tasks", h.GetMyTasks) // Requests (list, tree, and import) r.Get("/projects/{projectID}/requests", h.ListRequests) r.Get("/projects/{projectID}/requests/tree", h.ListRequestTree) r.Post("/projects/{projectID}/requests", h.CreateRequestList) r.Post("/projects/{projectID}/requests/new", h.CreateRequest) r.Post("/projects/{projectID}/sections", h.CreateSection) r.Post("/projects/{projectID}/requests/import", h.ImportRequests) // Request detail r.Get("/requests/{requestID}", h.GetRequestDetail) // Entry move (drag & drop) r.Post("/projects/{projectID}/entries/{entryID}/move", h.MoveEntry) // Answer links r.Get("/projects/{projectID}/requests/{requestID}/links", h.ListAnswerLinks) r.Post("/projects/{projectID}/requests/{requestID}/links", h.CreateAnswerLink) r.Delete("/projects/{projectID}/requests/{requestID}/links/{answerID}", h.DeleteAnswerLink) // Answers (picker) r.Get("/projects/{projectID}/answers", h.ListAnswers) // File upload/download/preview r.Post("/projects/{projectID}/objects", h.UploadObject) r.Get("/projects/{projectID}/objects/{objectID}", h.DownloadObject) r.Get("/projects/{projectID}/objects/{objectID}/preview", h.PreviewObject) // Request list visibility r.Patch("/projects/{projectID}/entries/{entryID}/visibility", h.UpdateRequestListVisibility) // Super admin endpoints // Organizations (platform level) r.Get("/orgs", h.ListOrgs) r.Post("/orgs", h.CreateOrg) r.Put("/orgs/{orgID}", h.UpdateOrg) r.Put("/admin/test-role", h.SetTestRole) r.Get("/orgs/{orgID}", h.GetOrg) r.Patch("/orgs/{orgID}", h.UpdateOrg) // Deal orgs (per project) r.Get("/projects/{projectID}/orgs", h.ListDealOrgs) r.Post("/projects/{projectID}/orgs", h.CreateDealOrg) r.Delete("/projects/{projectID}/orgs/{dealOrgID}", h.DeleteDealOrg) r.Post("/projects/{projectID}/orgs/add", h.AddOrgToDeal) // Scrape (LLM-powered org lookup) r.Post("/scrape/org", h.ScrapeOrg) r.Get("/admin/users", h.AdminListUsers) r.Get("/admin/projects", h.AdminListProjects) r.Get("/admin/audit", h.AdminAuditLog) r.Post("/admin/impersonate", h.AdminImpersonate) }) // OAuth metadata (unauthenticated, auto-discovery) r.Get("/.well-known/oauth-authorization-server", oauth.Metadata) r.Get("/.well-known/oauth-protected-resource", oauth.ResourceMetadata) // OAuth endpoints r.Get("/oauth/authorize", oauth.Authorize) r.Post("/oauth/authorize", oauth.AuthorizeApprove) r.Post("/oauth/token", oauth.Token) r.Post("/oauth/revoke", oauth.Revoke) // MCP endpoint (OAuth bearer auth) if mcpServer != nil { mcpHTTP := server.NewStreamableHTTPServer(mcpServer, server.WithEndpointPath("/mcp"), server.WithHTTPContextFunc(func(ctx context.Context, r *http.Request) context.Context { if uid := UserIDFromContext(r.Context()); uid != "" { return context.WithValue(ctx, ctxUserID, uid) } return ctx }), ) r.Group(func(r chi.Router) { r.Use(OAuthBearerAuth(db)) r.Handle("/mcp", mcpHTTP) }) } // Static files (portal + website CSS/JS) r.Handle("/static/*", http.StripPrefix("/static/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { dirs := []string{ "portal/static", "/opt/dealspace/portal/static", "website/static", "/opt/dealspace/website/static", } for _, dir := range dirs { fp := dir + "/" + r.URL.Path if _, err := os.Stat(fp); err == nil { http.FileServer(http.Dir(dir)).ServeHTTP(w, r) return } } http.NotFound(w, r) }))) // Portal app routes (serve templates, auth checked client-side via JS) r.Get("/app", func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/app/projects", http.StatusFound) }) r.Get("/app/login", h.ServeLogin) r.Get("/app/tasks", h.ServeAppTasks) r.Get("/app/projects", h.ServeAppProjects) r.Get("/app/projects/{id}", h.ServeAppProject) r.Get("/app/requests/{id}", h.ServeAppRequest) r.Get("/app/orgs", h.ServeAppOrgs) // Admin UI (super admin only, auth checked client-side) r.Get("/admin", h.ServeAdmin) r.Get("/admin/*", h.ServeAdmin) // Marketing website pages (template-based) r.Get("/", h.ServeWebsiteIndex) r.Get("/features", h.ServeWebsiteFeatures) r.Get("/pricing", h.ServeWebsitePricing) r.Get("/security", h.ServeWebsiteSecurity) r.Get("/privacy", h.ServeWebsitePrivacy) r.Get("/terms", h.ServeWebsiteTerms) r.Get("/dpa", h.ServeWebsiteDPA) r.Get("/soc2", h.ServeWebsiteSOC2) // Website static assets (embedded files) — serves at root, must be last if websiteFS != nil { websiteHandler := http.FileServerFS(websiteFS) r.Handle("/*", websiteHandler) } return r }