diff --git a/apps/backend/internal/api/handlers/diff.go b/apps/backend/internal/api/handlers/diff.go new file mode 100644 index 0000000..daaa053 --- /dev/null +++ b/apps/backend/internal/api/handlers/diff.go @@ -0,0 +1,35 @@ +package handlers + +import ( + "encoding/json" + "net/http" + + "github.com/goddhi/ucan-visualizer/internal/models" + "github.com/goddhi/ucan-visualizer/internal/services/diff" +) + +type DiffHandler struct { + service *diff.Service +} + +func NewDiffHandler(svc *diff.Service) *DiffHandler { + return &DiffHandler{ + service: svc, + } +} + +func (h *DiffHandler) GenerateDiff(w http.ResponseWriter, r *http.Request) { + var req models.DiffRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + respondError(w, http.StatusBadRequest, "Invalid request body", err) + return + } + + diffs, err := h.service.GenerateDiff(req.ParentToken, req.ChildToken) + if err != nil { + respondError(w, http.StatusUnprocessableEntity, "Failed to generate diff", err) + return + } + + respondJSON(w, http.StatusOK, models.DiffResponse{Diffs: diffs}) +} \ No newline at end of file diff --git a/apps/backend/internal/api/router.go b/apps/backend/internal/api/router.go index 850bf1f..ea541b6 100644 --- a/apps/backend/internal/api/router.go +++ b/apps/backend/internal/api/router.go @@ -7,15 +7,19 @@ import ( "github.com/gorilla/mux" "github.com/goddhi/ucan-visualizer/internal/api/handlers" + "github.com/goddhi/ucan-visualizer/internal/services/diff" ) func SetupRouter() http.Handler { r := mux.NewRouter() + diffService := diff.NewService() + // Initialize handlers parseHandler := handlers.NewParseHandler() validateHandler := handlers.NewValidateHandler() graphHandler := handlers.NewGraphHandler() + diffHandler := handlers.NewDiffHandler(diffService) r.HandleFunc("/", handlers.RootHandler).Methods("GET") @@ -44,6 +48,9 @@ func SetupRouter() http.Handler { api.HandleFunc("/graph/invocation", graphHandler.GenerateInvocationGraph).Methods("POST") api.HandleFunc("/graph/invocation/file", graphHandler.GenerateInvocationGraphFile).Methods("POST") + // diff endpoint + api.HandleFunc("/diff", diffHandler.GenerateDiff).Methods("POST") + cors := gorillahandlers.CORS( gorillahandlers.AllowedOrigins([]string{"*"}), gorillahandlers.AllowedMethods([]string{"GET", "POST", "OPTIONS"}), diff --git a/apps/backend/internal/models/diff.go b/apps/backend/internal/models/diff.go new file mode 100644 index 0000000..a30aa17 --- /dev/null +++ b/apps/backend/internal/models/diff.go @@ -0,0 +1,36 @@ +package models + +import "github.com/storacha/go-ucanto/ucan" + +// DiffRequest comes from the frontend with two raw token strings +type DiffRequest struct { + ParentToken string `json:"parent_token"` + ChildToken string `json:"child_token"` +} + +// CapabilityDiff describes what happened to a single permission +type CapabilityDiff struct { + // The capability as seen in the Child (Result) + ChildCap map[string]interface{} `json:"child_cap"` + + // The capability in the Parent that authorized this (if found) + ParentCap map[string]interface{} `json:"parent_cap,omitempty"` + + // Status: "UNCHANGED", "NARROWED", "ADDED" (Escalation), "REMOVED" + Status string `json:"status"` + + Message string `json:"message"` +} + +type DiffResponse struct { + Diffs []CapabilityDiff `json:"diffs"` +} + +// Helper to convert ucan.Capability to a JSON-friendly map +func CapToMap(c ucan.Capability[any]) map[string]interface{} { + return map[string]interface{}{ + "can": c.Can(), + "with": c.With(), + "nb": c.Nb(), + } +} \ No newline at end of file diff --git a/apps/backend/internal/services/diff/service.go b/apps/backend/internal/services/diff/service.go new file mode 100644 index 0000000..2cb64cb --- /dev/null +++ b/apps/backend/internal/services/diff/service.go @@ -0,0 +1,79 @@ +package diff + +import ( + "fmt" + + "github.com/goddhi/ucan-visualizer/internal/models" + "github.com/goddhi/ucan-visualizer/internal/services/parser" + "github.com/storacha/go-ucanto/ucan" + "github.com/storacha/go-ucanto/validator" +) + +type Service struct { + parser *parser.Service +} + +func NewService() *Service { + return &Service{ + parser: parser.NewService(), + } +} + +func (s *Service) GenerateDiff(parentStr, childStr string) ([]models.CapabilityDiff, error) { + // 1. Parse both tokens + parent, err := s.parser.Parse(parentStr) + if err != nil { + return nil, fmt.Errorf("failed to parse parent token: %w", err) + } + + child, err := s.parser.Parse(childStr) + if err != nil { + return nil, fmt.Errorf("failed to parse child token: %w", err) + } + + var diffs []models.CapabilityDiff + + // 2. Iterate through Child Capabilities (What we have now) + for _, childCap := range child.Capabilities() { + var matchedParent ucan.Capability[any] + foundMatch := false + + // 3. Find the Parent capability that authorizes this + for _, parentCap := range parent.Capabilities() { + // Leverage go-ucanto's logic to check if Child derives from Parent + // DefaultDerives checks if 'can' matches and 'with' is a subset + err := validator.DefaultDerives(childCap, parentCap) + if err == nil { + matchedParent = parentCap + foundMatch = true + break + } + } + + // 4. Determine the Status + diff := models.CapabilityDiff{ + ChildCap: models.CapToMap(childCap), + } + + if !foundMatch { + // ESCALATION: Child has a capability the Parent does not have + diff.Status = "ADDED" + diff.Message = "Escalation: Parent does not hold this capability." + } else { + diff.ParentCap = models.CapToMap(matchedParent) + + // Check if it was narrowed or identical + if childCap.With() == matchedParent.With() && childCap.Can() == matchedParent.Can() { + diff.Status = "UNCHANGED" + diff.Message = "Capability passed down exactly as is." + } else { + diff.Status = "NARROWED" + diff.Message = fmt.Sprintf("Restricted from '%s' to '%s'", matchedParent.With(), childCap.With()) + } + } + + diffs = append(diffs, diff) + } + + return diffs, nil +} \ No newline at end of file