Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions apps/backend/internal/api/handlers/diff.go
Original file line number Diff line number Diff line change
@@ -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})
}
7 changes: 7 additions & 0 deletions apps/backend/internal/api/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down Expand Up @@ -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"}),
Expand Down
36 changes: 36 additions & 0 deletions apps/backend/internal/models/diff.go
Original file line number Diff line number Diff line change
@@ -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(),
}
}
79 changes: 79 additions & 0 deletions apps/backend/internal/services/diff/service.go
Original file line number Diff line number Diff line change
@@ -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
}