// Package handlers provides HTTP handlers for the rdev API. package handlers import ( "context" "errors" "net/http" "github.com/orchard9/rdev/internal/auth" "github.com/orchard9/rdev/internal/domain" "github.com/orchard9/rdev/internal/logging" "github.com/orchard9/rdev/internal/port" "github.com/orchard9/rdev/internal/service" "github.com/orchard9/rdev/internal/validate" "github.com/orchard9/rdev/pkg/api" ) // CreateAndBuildHandler handles the combined create-project-and-build endpoint. type CreateAndBuildHandler struct { infraService *service.ProjectInfraService buildService *service.BuildService authService *auth.Service } // NewCreateAndBuildHandler creates a new create-and-build handler. func NewCreateAndBuildHandler( infraService *service.ProjectInfraService, buildService *service.BuildService, ) *CreateAndBuildHandler { return &CreateAndBuildHandler{ infraService: infraService, buildService: buildService, } } // WithAuthService sets an auth service for auto-granting project access to the creating key. func (h *CreateAndBuildHandler) WithAuthService(authService *auth.Service) *CreateAndBuildHandler { h.authService = authService return h } // Mount registers the create-and-build route. func (h *CreateAndBuildHandler) Mount(r api.Router) { // Requires both project execute (create) and build write (start build) r.With(auth.RequireScope(auth.ScopeBuildWrite, auth.ScopeAdmin)). Post("/project/create-and-build", h.CreateAndBuild) } // CreateAndBuildRequest is the request body for POST /project/create-and-build. type CreateAndBuildRequest struct { // Project creation fields Name string `json:"name"` Description string `json:"description,omitempty"` Private bool `json:"private,omitempty"` Template string `json:"template,omitempty"` // Build fields Prompt string `json:"prompt"` Variables map[string]string `json:"variables,omitempty"` AutoCommit bool `json:"auto_commit"` AutoPush bool `json:"auto_push"` CallbackURL string `json:"callback_url,omitempty"` // Access control: additional key IDs to grant access to the new project GrantToKeyIDs []string `json:"grant_to_key_ids,omitempty"` } // CreateAndBuildResponse is the response for POST /project/create-and-build. type CreateAndBuildResponse struct { // Project info ProjectID string `json:"project_id"` Name string `json:"name"` Domain string `json:"domain"` URL string `json:"url"` // Git info Git map[string]string `json:"git,omitempty"` // Build info TaskID string `json:"task_id"` Status string `json:"status"` StatusURL string `json:"status_url"` } // CreateAndBuild creates a project and immediately enqueues a build task. // POST /project/create-and-build func (h *CreateAndBuildHandler) CreateAndBuild(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), TimeoutHeavyWrite) defer cancel() if h.infraService == nil { api.WriteInternalError(w, r, "project infrastructure service not configured") return } if h.buildService == nil { api.WriteInternalError(w, r, "build service not configured") return } r.Body = http.MaxBytesReader(w, r.Body, maxRequestBodySize) var req CreateAndBuildRequest if err := api.DecodeJSON(r, &req); err != nil { api.WriteBadRequest(w, r, "invalid request body") return } v := validate.New() v.Required(req.Name, "name") v.Required(req.Prompt, "prompt") if err := v.Error(); err != nil { api.WriteBadRequest(w, r, err.Error()) return } // Validate callback URL to prevent SSRF if req.CallbackURL != "" { if err := domain.ValidateCallbackURL(req.CallbackURL); err != nil { api.WriteBadRequest(w, r, err.Error()) return } } // Step 1: Create the project projectResult, err := h.infraService.CreateProject(ctx, service.CreateProjectRequest{ Name: req.Name, Description: req.Description, Private: req.Private, Template: req.Template, }) if err != nil { if errors.Is(err, domain.ErrInvalidProjectName) { api.WriteBadRequest(w, r, err.Error()) return } log := logging.FromContext(ctx).WithHandler("CreateAndBuild") log.Error("project creation failed", logging.FieldError, err.Error(), logging.FieldProjectName, req.Name) api.WriteInternalError(w, r, "failed to create project") return } // Auto-grant: if creating key is restricted (non-admin with explicit project_ids), add the new project if h.authService != nil { log := logging.FromContext(ctx).WithHandler("CreateAndBuild") if apiKey := auth.GetAPIKey(ctx); apiKey != nil && !apiKey.HasScope(domain.ScopeAdmin) && apiKey.ProjectIDs != nil { newIDs := append(apiKey.ProjectIDs, domain.ProjectID(projectResult.ProjectID)) if err := h.authService.Update(ctx, string(apiKey.ID), port.APIKeyUpdate{ProjectIDs: &newIDs}); err != nil { log.Warn("failed to auto-grant creating key access to new project", logging.FieldError, err.Error(), logging.FieldProjectID, projectResult.ProjectID, ) // non-fatal: project still usable, admin can grant access manually } } // Grant to additional key IDs specified in request for _, keyID := range req.GrantToKeyIDs { key, err := h.authService.Get(ctx, keyID) if err != nil || key == nil || !key.IsActive() { log.Warn("failed to grant access: key not found or inactive", "key_id", keyID) continue } // Unrestricted or admin keys already have access if key.ProjectIDs == nil || key.HasScope(domain.ScopeAdmin) { continue } newIDs := append(key.ProjectIDs, domain.ProjectID(projectResult.ProjectID)) if err := h.authService.Update(ctx, keyID, port.APIKeyUpdate{ProjectIDs: &newIDs}); err != nil { log.Warn("failed to grant access to key", "key_id", keyID, logging.FieldError, err.Error(), ) } } } // Step 2: Enqueue the build task spec := domain.BuildSpec{ Prompt: req.Prompt, Template: req.Template, Variables: req.Variables, AutoCommit: req.AutoCommit, AutoPush: req.AutoPush, CallbackURL: req.CallbackURL, GitCloneURL: projectResult.CloneHTTP, // Required for git ops on shared worker pods } taskID, err := h.buildService.StartBuild(ctx, projectResult.ProjectID, spec) if err != nil { log := logging.FromContext(ctx).WithHandler("CreateAndBuild") log.Error("build enqueue failed after project creation", logging.FieldError, err.Error(), logging.FieldProjectID, projectResult.ProjectID, ) // Project was created but build failed to enqueue. // Return the project info with a generic error and retry URL. api.WriteJSON(w, r, http.StatusCreated, map[string]any{ "project_id": projectResult.ProjectID, "name": projectResult.Name, "domain": projectResult.Domain, "url": projectResult.URL, "build_error": "project created but build failed to enqueue", "retry_url": "/projects/" + projectResult.ProjectID + "/builds", }) return } resp := CreateAndBuildResponse{ ProjectID: projectResult.ProjectID, Name: projectResult.Name, Domain: projectResult.Domain, URL: projectResult.URL, TaskID: taskID, Status: "pending", StatusURL: "/builds/" + taskID, } if projectResult.CloneHTTP != "" { resp.Git = map[string]string{ "owner": projectResult.GitRepoOwner, "name": projectResult.GitRepoName, "clone_ssh": projectResult.CloneSSH, "clone_http": projectResult.CloneHTTP, "html_url": projectResult.HTMLURL, } } api.WriteCreated(w, r, resp) }