rdev/internal/handlers/verify.go
jordan 4f01015132
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
feat: implement project access enforcement and management API
- Fix no-op RequireProjectAccess middleware to enforce project_ids
- Apply project access middleware to all project-scoped routes
- Filter GET /projects by allowed project IDs for restricted keys
- Add GET /me endpoint with key identity, scopes, and project access info
- Add PATCH /keys/{id} for partial key updates (name, scopes, project_ids, allowed_ips, expires_in)
- Add GET/POST/DELETE /projects/{id}/access for project-centric access management
- Auto-grant creating key access when using POST /project/create-and-build
- Accept grant_to_key_ids in create-and-build to grant multiple keys on project creation
- Move newProvisionerWithDeps test helper from production code to test file

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-21 15:38:37 -07:00

365 lines
10 KiB
Go

// Package handlers provides HTTP handlers for the rdev API.
package handlers
import (
"context"
"errors"
"fmt"
"net/http"
"strconv"
"time"
"github.com/go-chi/chi/v5"
"github.com/orchard9/rdev/internal/auth"
"github.com/orchard9/rdev/internal/domain"
"github.com/orchard9/rdev/internal/port"
"github.com/orchard9/rdev/internal/service"
"github.com/orchard9/rdev/internal/worker"
"github.com/orchard9/rdev/pkg/api"
)
// VerifyHandler handles visual verification endpoints.
type VerifyHandler struct {
verifyService *service.VerifyService
streams port.StreamPublisher
}
// NewVerifyHandler creates a new verify handler.
func NewVerifyHandler(verifyService *service.VerifyService, streams port.StreamPublisher) *VerifyHandler {
return &VerifyHandler{
verifyService: verifyService,
streams: streams,
}
}
// Mount registers the verify routes.
func (h *VerifyHandler) Mount(r api.Router) {
r.Route("/verify", func(r chi.Router) {
r.With(auth.RequireScope(auth.ScopeVerifyWrite, auth.ScopeAdmin)).Post("/", h.Submit)
r.With(auth.RequireScope(auth.ScopeVerifyRead, auth.ScopeAdmin)).Get("/{taskId}", h.Get)
r.With(auth.RequireScope(auth.ScopeVerifyRead, auth.ScopeAdmin)).Get("/{taskId}/stream", h.Stream)
r.With(auth.RequireScope(auth.ScopeVerifyWrite, auth.ScopeAdmin)).Delete("/{taskId}", h.Cancel)
})
r.With(auth.RequireScope(auth.ScopeVerifyRead, auth.ScopeAdmin), auth.RequireProjectAccess("id")).Get("/projects/{id}/verify", h.ListByProject)
}
// SubmitVerifyRequest is the request body for POST /verify.
type SubmitVerifyRequest struct {
ProjectID string `json:"project_id"`
URL string `json:"url"`
Viewports []string `json:"viewports,omitempty"`
WaitFor string `json:"wait_for,omitempty"`
WaitTimeout int `json:"wait_timeout,omitempty"`
FullPage bool `json:"full_page,omitempty"`
Video bool `json:"video,omitempty"`
CallbackURL string `json:"callback_url,omitempty"`
}
// SubmitVerifyResponse is the response for POST /verify.
type SubmitVerifyResponse struct {
TaskID string `json:"task_id"`
StatusURL string `json:"status_url"`
StreamURL string `json:"stream_url"`
}
// VerifyTaskResponse is the response for GET /verify/{taskId}.
type VerifyTaskResponse struct {
ID string `json:"id"`
ProjectID string `json:"project_id"`
Status string `json:"status"`
URL string `json:"url"`
Screenshots map[string]string `json:"screenshots,omitempty"`
Video string `json:"video,omitempty"`
Error string `json:"error,omitempty"`
CreatedAt string `json:"created_at"`
CompletedAt string `json:"completed_at,omitempty"`
DurationMs int64 `json:"duration_ms,omitempty"`
}
// Submit enqueues a visual verification task.
// POST /verify
func (h *VerifyHandler) Submit(w http.ResponseWriter, r *http.Request) {
var req SubmitVerifyRequest
if err := api.DecodeJSON(r, &req); err != nil {
api.WriteBadRequest(w, r, "invalid request body")
return
}
// Validate required fields
if req.ProjectID == "" {
api.WriteBadRequest(w, r, "project_id is required")
return
}
if req.URL == "" {
api.WriteBadRequest(w, r, "url is required")
return
}
// Validate callback URL if provided
if req.CallbackURL != "" {
if err := domain.ValidateCallbackURL(req.CallbackURL); err != nil {
api.WriteBadRequest(w, r, err.Error())
return
}
}
// Build verify spec
spec := domain.VerifySpec{
URL: req.URL,
Viewports: req.Viewports,
WaitFor: req.WaitFor,
WaitTimeout: req.WaitTimeout,
FullPage: req.FullPage,
Video: req.Video,
CallbackURL: req.CallbackURL,
}
ctx, cancel := context.WithTimeout(r.Context(), TimeoutStandard)
defer cancel()
taskID, err := h.verifyService.SubmitCapture(ctx, req.ProjectID, spec)
if err != nil {
if errors.Is(err, domain.ErrVerifyURLRequired) {
api.WriteBadRequest(w, r, err.Error())
return
}
api.WriteInternalError(w, r, "failed to submit verify task")
return
}
api.WriteCreated(w, r, SubmitVerifyResponse{
TaskID: taskID,
StatusURL: "/verify/" + taskID,
StreamURL: "/verify/" + taskID + "/stream",
})
}
// Get retrieves a verify task by ID.
// GET /verify/{taskId}
func (h *VerifyHandler) Get(w http.ResponseWriter, r *http.Request) {
taskID := chi.URLParam(r, "taskId")
if taskID == "" {
api.WriteBadRequest(w, r, "task ID is required")
return
}
ctx, cancel := context.WithTimeout(r.Context(), TimeoutFastLookup)
defer cancel()
task, err := h.verifyService.GetCapture(ctx, taskID)
if err != nil {
if errors.Is(err, domain.ErrWorkTaskNotFound) {
api.WriteNotFound(w, r, fmt.Sprintf("verify task not found: %s", taskID))
return
}
api.WriteInternalError(w, r, "failed to get verify task")
return
}
api.WriteSuccess(w, r, toVerifyTaskResponse(task))
}
// Stream streams verify task events via SSE.
// GET /verify/{taskId}/stream
func (h *VerifyHandler) Stream(w http.ResponseWriter, r *http.Request) {
taskID := chi.URLParam(r, "taskId")
if taskID == "" {
api.WriteBadRequest(w, r, "task ID is required")
return
}
lastEventID := r.Header.Get("Last-Event-ID")
// Verify task exists
ctx, cancel := context.WithTimeout(r.Context(), TimeoutFastLookup)
defer cancel()
_, err := h.verifyService.GetCapture(ctx, taskID)
if err != nil {
if errors.Is(err, domain.ErrWorkTaskNotFound) {
api.WriteNotFound(w, r, fmt.Sprintf("verify task not found: %s", taskID))
return
}
api.WriteInternalError(w, r, "failed to get verify task")
return
}
// Set SSE headers
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("Access-Control-Allow-Origin", "*")
flusher, ok := w.(http.Flusher)
if !ok {
api.WriteInternalError(w, r, "SSE not supported")
return
}
// Subscribe to events
var events <-chan port.StreamEvent
var cleanup func()
if lastEventID != "" {
events, cleanup = h.streams.SubscribeFromID(taskID, lastEventID)
} else {
events, cleanup = h.streams.Subscribe(taskID)
}
defer cleanup()
// Send initial connected event
writeSSE(w, flusher, "connected", map[string]any{
"task_id": taskID,
"reconnecting": lastEventID != "",
})
// Stream events until client disconnects or stream closes
reqCtx := r.Context()
heartbeat := time.NewTicker(30 * time.Second)
defer heartbeat.Stop()
for {
select {
case <-reqCtx.Done():
return
case event, ok := <-events:
if !ok {
return
}
writeSSEWithID(w, flusher, event.ID, event.Type, event.Data)
// Check for terminal events
if event.Type == worker.VerifyEventCompleted || event.Type == worker.VerifyEventFailed {
return
}
case <-heartbeat.C:
writeSSE(w, flusher, "heartbeat", map[string]any{
"timestamp": time.Now().UTC().Format(time.RFC3339),
})
}
}
}
// Cancel cancels a pending verify task.
// DELETE /verify/{taskId}
func (h *VerifyHandler) Cancel(w http.ResponseWriter, r *http.Request) {
taskID := chi.URLParam(r, "taskId")
if taskID == "" {
api.WriteBadRequest(w, r, "task ID is required")
return
}
ctx, cancel := context.WithTimeout(r.Context(), TimeoutStandard)
defer cancel()
if err := h.verifyService.CancelCapture(ctx, taskID); err != nil {
if errors.Is(err, domain.ErrWorkTaskNotFound) {
api.WriteNotFound(w, r, fmt.Sprintf("verify task not found: %s", taskID))
return
}
api.WriteBadRequest(w, r, err.Error())
return
}
api.WriteSuccess(w, r, map[string]any{
"task_id": taskID,
"status": "cancelled",
"message": "verify task cancelled successfully",
})
}
// ListByProject returns verify tasks for a project.
// GET /projects/{id}/verify?limit=50&offset=0
func (h *VerifyHandler) ListByProject(w http.ResponseWriter, r *http.Request) {
projectID := chi.URLParam(r, "id")
if projectID == "" {
api.WriteBadRequest(w, r, "project ID is required")
return
}
// Parse pagination options
opts := domain.DefaultWorkListOptions()
if limitStr := r.URL.Query().Get("limit"); limitStr != "" {
limit, err := strconv.Atoi(limitStr)
if err != nil {
api.WriteBadRequest(w, r, "limit must be a valid integer")
return
}
opts.Limit = limit
}
if offsetStr := r.URL.Query().Get("offset"); offsetStr != "" {
offset, err := strconv.Atoi(offsetStr)
if err != nil {
api.WriteBadRequest(w, r, "offset must be a valid integer")
return
}
opts.Offset = offset
}
ctx, cancel := context.WithTimeout(r.Context(), TimeoutLookup)
defer cancel()
result, err := h.verifyService.ListCaptures(ctx, projectID, opts)
if err != nil {
api.WriteInternalError(w, r, "failed to list verify tasks")
return
}
dtos := make([]*VerifyTaskResponse, len(result.Tasks))
for i, task := range result.Tasks {
dtos[i] = toVerifyTaskResponse(task)
}
api.WriteSuccess(w, r, map[string]any{
"tasks": dtos,
"project_id": projectID,
"total": result.Total,
"limit": result.Limit,
"offset": result.Offset,
})
}
// toVerifyTaskResponse converts a WorkTask to a VerifyTaskResponse.
func toVerifyTaskResponse(task *domain.WorkTask) *VerifyTaskResponse {
if task == nil {
return nil
}
resp := &VerifyTaskResponse{
ID: task.ID,
ProjectID: task.ProjectID,
Status: string(task.Status),
CreatedAt: task.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
Error: task.Error,
}
// Extract URL from spec
if url, ok := task.Spec["url"].(string); ok {
resp.URL = url
}
// Extract results from task.Result artifacts
if task.Result != nil && task.Result.Artifacts != nil {
resp.Screenshots = make(map[string]string)
for key, value := range task.Result.Artifacts {
if len(key) > 11 && key[:11] == "screenshot_" {
viewport := key[11:]
resp.Screenshots[viewport] = value
}
if key == "video" {
resp.Video = value
}
if key == "duration_ms" {
if durationMs, err := strconv.ParseInt(value, 10, 64); err == nil {
resp.DurationMs = durationMs
}
}
}
}
if task.CompletedAt != nil {
resp.CompletedAt = task.CompletedAt.Format("2006-01-02T15:04:05Z07:00")
}
return resp
}