From db5f24ad4da6a25163973c931c760e4a1563a5cb Mon Sep 17 00:00:00 2001 From: Pranav Aggarwal Date: Wed, 18 Feb 2026 18:46:35 +0530 Subject: [PATCH 1/4] feat: implement plan get and plan set commands --- cmd/plan/get.go | 91 ++++++++++++++++++ cmd/plan/root.go | 20 ++++ cmd/plan/set.go | 78 +++++++++++++++ cmd/root.go | 2 + pkg/client/nodevalue/nodevalue.go | 28 ++++++ pkg/client/plan.go | 154 ++++++++++++++++++++++++++++++ 6 files changed, 373 insertions(+) create mode 100644 cmd/plan/get.go create mode 100644 cmd/plan/root.go create mode 100644 cmd/plan/set.go create mode 100644 pkg/client/plan.go diff --git a/cmd/plan/get.go b/cmd/plan/get.go new file mode 100644 index 00000000..b971e37d --- /dev/null +++ b/cmd/plan/get.go @@ -0,0 +1,91 @@ +package plan + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/mitchellh/go-wordwrap" + "github.com/spf13/cobra" + "github.com/storacha/go-ucanto/did" + + "github.com/storacha/guppy/internal/cmdutil" + "github.com/storacha/guppy/pkg/config" + "github.com/storacha/guppy/pkg/didmailto" +) + +var getFlags struct { + jsonOutput bool +} + +func init() { + getCmd.Flags().BoolVar(&getFlags.jsonOutput, "json", false, "Output in JSON format") +} + +var getCmd = &cobra.Command{ + Use: "get [account-did]", + Short: "Get the billing plan for an account", + Long: wordwrap.WrapString( + "Displays the billing plan the current account is subscribed to. "+ + "If no account DID is provided, it defaults to the currently logged-in account.", + 80), + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + + cfg, err := config.Load[config.Config]() + if err != nil { + return err + } + c := cmdutil.MustGetClient(cfg.Repo.Dir) + + var accountDID did.DID + + if len(args) > 0 { + accountDID, err = didmailto.FromInput(args[0]) + if err != nil { + return fmt.Errorf("invalid account DID: %w", err) + } + } else { + accounts, err := c.Accounts() + if err != nil { + return fmt.Errorf("listing accounts: %w", err) + } + if len(accounts) == 0 { + return fmt.Errorf("no accounts found. Please login with 'guppy login'") + } + accountDID = accounts[0] + } + + plan, err := c.PlanGet(ctx, accountDID) + if err != nil { + if strings.Contains(err.Error(), "billing profile not found") { + if getFlags.jsonOutput { + fmt.Println(`{"error": "No billing profile found"}`) + return nil + } + fmt.Printf(" No billing profile found for %s\n", accountDID) + fmt.Println(" Please visit the console to set up a plan: https://console.storacha.network") + return nil + } + return fmt.Errorf("getting plan: %w", err) + } + + if getFlags.jsonOutput { + jsonBytes, err := json.MarshalIndent(plan, "", " ") + if err != nil { + return fmt.Errorf("marshaling output: %w", err) + } + fmt.Println(string(jsonBytes)) + } else { + fmt.Printf("Plan Details for %s:\n", accountDID) + fmt.Printf(" Product: %s\n", plan.Product) + fmt.Printf(" Limit: %s\n", plan.Limit) + if plan.UpdatedAt != "" { + fmt.Printf(" Updated: %s\n", plan.UpdatedAt) + } + } + + return nil + }, +} \ No newline at end of file diff --git a/cmd/plan/root.go b/cmd/plan/root.go new file mode 100644 index 00000000..cc91da9e --- /dev/null +++ b/cmd/plan/root.go @@ -0,0 +1,20 @@ +package plan + +import ( + "github.com/mitchellh/go-wordwrap" + "github.com/spf13/cobra" +) + +var Cmd = &cobra.Command{ + Use: "plan", + Short: "Manage billing plans", + Long: wordwrap.WrapString( + "Inspect and manage the billing plan associated with your Storacha account.", + 80), +} + +func init() { + Cmd.AddCommand( + getCmd, + ) +} diff --git a/cmd/plan/set.go b/cmd/plan/set.go new file mode 100644 index 00000000..1f32d96c --- /dev/null +++ b/cmd/plan/set.go @@ -0,0 +1,78 @@ +package plan + +import ( + "fmt" + "strings" + + "github.com/mitchellh/go-wordwrap" + "github.com/spf13/cobra" + "github.com/storacha/go-ucanto/did" + + "github.com/storacha/guppy/internal/cmdutil" + "github.com/storacha/guppy/pkg/config" + "github.com/storacha/guppy/pkg/didmailto" +) + +var setCmd = &cobra.Command{ + Use: "set ", + Short: "Set the billing plan for an account", + Long: wordwrap.WrapString( + "Sets the billing plan for the account. You must provide the Product DID (e.g., did:web:starter.storacha.network). "+ + "Optionally provide the account DID via --account, otherwise it defaults to the current account.", + 80), + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + productDIDStr := args[0] + + productDID, err := did.Parse(productDIDStr) + if err != nil { + return fmt.Errorf("invalid product DID: %w", err) + } + + cfg, err := config.Load[config.Config]() + if err != nil { + return err + } + c := cmdutil.MustGetClient(cfg.Repo.Dir) + + var accountDID did.DID + if cmd.Flag("account").Changed { + val, _ := cmd.Flags().GetString("account") + accountDID, err = didmailto.FromInput(val) + if err != nil { + return fmt.Errorf("invalid account DID: %w", err) + } + } else { + accounts, err := c.Accounts() + if err != nil { + return err + } + if len(accounts) == 0 { + return fmt.Errorf("no accounts found") + } + accountDID = accounts[0] + } + + fmt.Printf("Setting plan for %s to %s...\n", accountDID, productDID) + _, err = c.PlanSet(ctx, accountDID, productDID) + + if err != nil { + if strings.Contains(err.Error(), "billing profile not found") { + fmt.Println("\n Update Failed: No Billing Profile Found") + fmt.Println(" To change plans, you must first set up a payment method.") + fmt.Println(" Please visit the console: https://console.storacha.network") + return nil + } + return err + } + + fmt.Println("Success! Plan updated.") + return nil + }, +} + +func init() { + setCmd.Flags().String("account", "", "The account DID to update") + Cmd.AddCommand(setCmd) +} diff --git a/cmd/root.go b/cmd/root.go index 6a97bdf4..db00548f 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -19,6 +19,7 @@ import ( "github.com/storacha/guppy/cmd/account" "github.com/storacha/guppy/cmd/delegation" "github.com/storacha/guppy/cmd/gateway" + "github.com/storacha/guppy/cmd/plan" "github.com/storacha/guppy/cmd/proof" "github.com/storacha/guppy/cmd/space" "github.com/storacha/guppy/cmd/upload" @@ -103,6 +104,7 @@ func init() { delegation.Cmd, account.Cmd, blob.Cmd, + plan.Cmd, ) } diff --git a/pkg/client/nodevalue/nodevalue.go b/pkg/client/nodevalue/nodevalue.go index 1168768f..6efd5690 100644 --- a/pkg/client/nodevalue/nodevalue.go +++ b/pkg/client/nodevalue/nodevalue.go @@ -1,9 +1,14 @@ package nodevalue import ( + "bytes" + "encoding/json" "fmt" + "strings" "github.com/ipld/go-ipld-prime" + "github.com/ipld/go-ipld-prime/codec/dagjson" + "github.com/ipld/go-ipld-prime/node/basicnode" ) // NodeValue converts an arbitrary IPLD node to a Go value. This is useful as a @@ -66,3 +71,26 @@ func NodeValue(node ipld.Node) (res any, err error) { return nil, fmt.Errorf("unsupported node kind: %s", node.Kind()) } } + +// FromAny converts an arbitrary Go value (struct, map, etc) to an IPLD Node. +func FromAny(val any) (ipld.Node, error) { + if val == nil { + nb := basicnode.Prototype.Any.NewBuilder() + if err := dagjson.Decode(nb, strings.NewReader("null")); err != nil { + return nil, fmt.Errorf("failed to create null node: %w", err) + } + return nb.Build(), nil + } + + b, err := json.Marshal(val) + if err != nil { + return nil, fmt.Errorf("marshaling to json: %w", err) + } + + nb := basicnode.Prototype.Any.NewBuilder() + if err := dagjson.Decode(nb, bytes.NewReader(b)); err != nil { + return nil, fmt.Errorf("decoding json to ipld: %w", err) + } + + return nb.Build(), nil +} diff --git a/pkg/client/plan.go b/pkg/client/plan.go new file mode 100644 index 00000000..75e643cb --- /dev/null +++ b/pkg/client/plan.go @@ -0,0 +1,154 @@ +package client + +import ( + "context" + "fmt" + "sort" + + "github.com/ipld/go-ipld-prime" + ucancap "github.com/storacha/go-libstoracha/capabilities/ucan" + uclient "github.com/storacha/go-ucanto/client" + "github.com/storacha/go-ucanto/core/delegation" + "github.com/storacha/go-ucanto/core/invocation" + "github.com/storacha/go-ucanto/core/receipt" + "github.com/storacha/go-ucanto/core/result" + "github.com/storacha/go-ucanto/did" + "github.com/storacha/go-ucanto/ucan" + "github.com/storacha/go-ucanto/validator" + + "github.com/storacha/guppy/pkg/agentstore" + "github.com/storacha/guppy/pkg/client/nodevalue" +) + +const ( + PlanGetCan = "plan/get" + PlanSetCan = "plan/set" +) + +type PlanResult struct { + Limit string `json:"limit"` + Product string `json:"product"` + UpdatedAt string `json:"updatedAt"` +} + +type MapCaveat map[string]any + +func (m MapCaveat) ToIPLD() (ipld.Node, error) { + return nodevalue.FromAny(m) +} + +func (c *Client) PlanGet(ctx context.Context, account did.DID) (*PlanResult, error) { + return executePlanRequest(ctx, c, PlanGetCan, account, MapCaveat{}) +} + +func (c *Client) PlanSet(ctx context.Context, account did.DID, product did.DID) (*PlanResult, error) { + return executePlanRequest(ctx, c, PlanSetCan, account, MapCaveat{"product": product.String()}) +} + +func executePlanRequest( + ctx context.Context, + c *Client, + can string, + account did.DID, + caveat MapCaveat, +) (*PlanResult, error) { + cap := ucan.NewCapability(can, account.String(), caveat) + + delegations, err := c.Proofs( + agentstore.CapabilityQuery{Can: can, With: account.String()}, + agentstore.CapabilityQuery{Can: "*", With: account.String()}, + ) + if err != nil { + return nil, fmt.Errorf("finding proofs for %s: %w", can, err) + } + + if len(delegations) == 0 { + return nil, fmt.Errorf("no authorizations found for account %s", account) + } + + sort.SliceStable(delegations, func(i, j int) bool { + return isAttestation(delegations[i]) && !isAttestation(delegations[j]) + }) + + var proofs []delegation.Proof + for _, d := range delegations { + proofs = append(proofs, delegation.FromDelegation(d)) + } + + inv, err := invocation.Invoke( + c.Issuer(), + c.Connection().ID(), + cap, + delegation.WithProof(proofs...), + ) + if err != nil { + return nil, fmt.Errorf("creating invocation: %w", err) + } + + resp, err := uclient.Execute(ctx, []invocation.Invocation{inv}, c.Connection()) + if err != nil { + return nil, fmt.Errorf("executing invocation: %w", err) + } + + rcptLink, ok := resp.Get(inv.Link()) + if !ok { + return nil, fmt.Errorf("receipt not found") + } + + anyRcpt, err := receipt.NewAnyReceiptReader().Read(rcptLink, resp.Blocks()) + if err != nil { + return nil, fmt.Errorf("reading receipt: %w", err) + } + + okNode, errNode := result.Unwrap(anyRcpt.Out()) + + if errNode != nil { + val, _ := nodevalue.NodeValue(errNode) + if errMap, ok := val.(map[string]any); ok { + msg, _ := errMap["message"].(string) + name, _ := errMap["name"].(string) + + if msg == "billing profile not found" || msg == "record not found" || name == "PlanNotFound" { + return nil, fmt.Errorf("billing profile not found") + } + + if msg != "" { + return nil, fmt.Errorf("server error: %s", msg) + } + } + return nil, fmt.Errorf("server error: %v", val) + } + + val, err := nodevalue.NodeValue(okNode) + if err != nil { + return nil, fmt.Errorf("decoding result: %w", err) + } + + res := &PlanResult{} + if m, ok := val.(map[string]any); ok { + if innerOk, hasOk := m["ok"].(map[string]any); hasOk { + m = innerOk + } + if p, ok := m["product"].(string); ok { + res.Product = p + } + if l, ok := m["limit"]; ok { + res.Limit = fmt.Sprintf("%v", l) + } + if u, ok := m["updatedAt"]; ok { + res.UpdatedAt = fmt.Sprintf("%v", u) + } + } + + return res, nil +} + +func isAttestation(d delegation.Delegation) bool { + for _, rawCap := range d.Capabilities() { + source := validator.NewSource(rawCap, d) + if _, err := ucancap.Attest.Match(source); err == nil { + return true + } + } + return false +} From b98fb6bf9b5162bc7a7648cda4490b7a3d78e7f1 Mon Sep 17 00:00:00 2001 From: Pranav Aggarwal Date: Fri, 20 Feb 2026 17:40:19 +0530 Subject: [PATCH 2/4] feat: add usage report command --- cmd/plan/get.go | 2 +- cmd/root.go | 2 + cmd/usage/report.go | 73 ++++++++++++++++++++ cmd/usage/root.go | 15 +++++ pkg/client/plan.go | 4 +- pkg/client/usage.go | 159 ++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 252 insertions(+), 3 deletions(-) create mode 100644 cmd/usage/report.go create mode 100644 cmd/usage/root.go create mode 100644 pkg/client/usage.go diff --git a/cmd/plan/get.go b/cmd/plan/get.go index b971e37d..2ebe219f 100644 --- a/cmd/plan/get.go +++ b/cmd/plan/get.go @@ -88,4 +88,4 @@ var getCmd = &cobra.Command{ return nil }, -} \ No newline at end of file +} diff --git a/cmd/root.go b/cmd/root.go index db00548f..8a9212a0 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -23,6 +23,7 @@ import ( "github.com/storacha/guppy/cmd/proof" "github.com/storacha/guppy/cmd/space" "github.com/storacha/guppy/cmd/upload" + "github.com/storacha/guppy/cmd/usage" ) var ( @@ -105,6 +106,7 @@ func init() { account.Cmd, blob.Cmd, plan.Cmd, + usage.Cmd, ) } diff --git a/cmd/usage/report.go b/cmd/usage/report.go new file mode 100644 index 00000000..e3c86626 --- /dev/null +++ b/cmd/usage/report.go @@ -0,0 +1,73 @@ +package usage + +import ( + "fmt" + "time" + + "github.com/dustin/go-humanize" + "github.com/spf13/cobra" + "github.com/storacha/go-ucanto/did" + + "github.com/storacha/guppy/internal/cmdutil" + "github.com/storacha/guppy/pkg/config" +) + +func newReportCmd() *cobra.Command { + return &cobra.Command{ + Use: "report [space-did]", + Short: "Show storage usage", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + + cfg, err := config.Load[config.Config]() + if err != nil { + return err + } + + c := cmdutil.MustGetClient(cfg.Repo.Dir) + + var spaceDID did.DID + + if len(args) > 0 { + parsedDID, err := did.Parse(args[0]) + if err != nil { + return fmt.Errorf("invalid space DID: %w", err) + } + spaceDID = parsedDID + } else { + spaces, err := c.Spaces() + if err != nil { + return fmt.Errorf("listing spaces: %w", err) + } + if len(spaces) == 0 { + return fmt.Errorf("no spaces found") + } + spaceDID = spaces[0].DID() + } + + to := time.Now().UTC() + from := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC) + + fmt.Printf("Fetching usage for space %s...\n", spaceDID) + + reports, err := c.UsageReport(ctx, spaceDID, from, to) + if err != nil { + return err + } + + fmt.Println("---------------------------------------------------") + if len(reports) == 0 { + fmt.Println("No usage data found for this period.") + } else { + for providerDID, report := range reports { + fmt.Printf("Provider: %s\n", providerDID) + fmt.Printf("Total Usage: %s\n", humanize.IBytes(report.Size)) + fmt.Println("---------------------------------------------------") + } + } + + return nil + }, + } +} diff --git a/cmd/usage/root.go b/cmd/usage/root.go new file mode 100644 index 00000000..660995a9 --- /dev/null +++ b/cmd/usage/root.go @@ -0,0 +1,15 @@ +package usage + +import ( + "github.com/spf13/cobra" +) + +var Cmd = &cobra.Command{ + Use: "usage", + Short: "Manage usage reports", + Long: "View storage usage reports for your spaces.", +} + +func init() { + Cmd.AddCommand(newReportCmd()) +} diff --git a/pkg/client/plan.go b/pkg/client/plan.go index 75e643cb..301eb167 100644 --- a/pkg/client/plan.go +++ b/pkg/client/plan.go @@ -109,9 +109,9 @@ func executePlanRequest( name, _ := errMap["name"].(string) if msg == "billing profile not found" || msg == "record not found" || name == "PlanNotFound" { - return nil, fmt.Errorf("billing profile not found") + return nil, fmt.Errorf("billing profile not found") } - + if msg != "" { return nil, fmt.Errorf("server error: %s", msg) } diff --git a/pkg/client/usage.go b/pkg/client/usage.go new file mode 100644 index 00000000..44e96298 --- /dev/null +++ b/pkg/client/usage.go @@ -0,0 +1,159 @@ +package client + +import ( + "context" + "fmt" + "strconv" + "time" + + "github.com/ipld/go-ipld-prime" + uclient "github.com/storacha/go-ucanto/client" + "github.com/storacha/go-ucanto/core/delegation" + "github.com/storacha/go-ucanto/core/invocation" + "github.com/storacha/go-ucanto/core/receipt" + "github.com/storacha/go-ucanto/core/result" + "github.com/storacha/go-ucanto/did" + "github.com/storacha/go-ucanto/ucan" + + "github.com/storacha/guppy/pkg/agentstore" + "github.com/storacha/guppy/pkg/client/nodevalue" +) + +const UsageReportCan = "usage/report" + +type UsagePeriod struct { + From int64 `json:"from"` + To int64 `json:"to"` +} + +type UsageCaveat struct { + Period UsagePeriod `json:"period"` +} + +func (u UsageCaveat) ToIPLD() (ipld.Node, error) { + return nodevalue.FromAny(u) +} + +type UsageReport struct { + Provider string + Size uint64 +} + +func (c *Client) UsageReport(ctx context.Context, space did.DID, from, to time.Time) (map[string]UsageReport, error) { + caveat := UsageCaveat{ + Period: UsagePeriod{ + From: from.Unix(), + To: to.Unix(), + }, + } + + cap := ucan.NewCapability(UsageReportCan, space.String(), caveat) + + delegations, err := c.Proofs( + agentstore.CapabilityQuery{Can: UsageReportCan, With: space.String()}, + agentstore.CapabilityQuery{Can: "*", With: space.String()}, + ) + if err != nil { + return nil, fmt.Errorf("finding proofs: %w", err) + } + + if len(delegations) == 0 { + return nil, fmt.Errorf("no authorizations found for space %s", space) + } + + var proofs []delegation.Proof + for _, d := range delegations { + proofs = append(proofs, delegation.FromDelegation(d)) + } + + inv, err := invocation.Invoke( + c.Issuer(), + c.Connection().ID(), + cap, + delegation.WithProof(proofs...), + ) + if err != nil { + return nil, fmt.Errorf("creating invocation: %w", err) + } + + resp, err := uclient.Execute(ctx, []invocation.Invocation{inv}, c.Connection()) + if err != nil { + return nil, fmt.Errorf("executing invocation: %w", err) + } + + rcptLink, ok := resp.Get(inv.Link()) + if !ok { + return nil, fmt.Errorf("receipt not found") + } + + anyRcpt, err := receipt.NewAnyReceiptReader().Read(rcptLink, resp.Blocks()) + if err != nil { + return nil, fmt.Errorf("reading receipt: %w", err) + } + + okNode, errNode := result.Unwrap(anyRcpt.Out()) + + if errNode != nil { + val, _ := nodevalue.NodeValue(errNode) + if errMap, ok := val.(map[string]any); ok { + if msg, ok := errMap["message"].(string); ok && msg != "" { + return nil, fmt.Errorf("server error: %s", msg) + } + } + return nil, fmt.Errorf("server error: %v", val) + } + + val, err := nodevalue.NodeValue(okNode) + if err != nil { + return nil, fmt.Errorf("decoding result: %w", err) + } + + reports := make(map[string]UsageReport) + if m, ok := val.(map[string]any); ok { + if innerOk, hasOk := m["ok"].(map[string]any); hasOk { + m = innerOk + } + for providerDID, data := range m { + if pdMap, ok := data.(map[string]any); ok { + report := UsageReport{Provider: providerDID} + + if sizeMap, ok := pdMap["size"].(map[string]any); ok { + report.Size = parseUint64(sizeMap["final"]) + } else if sizeVal, ok := pdMap["size"]; ok { + report.Size = parseUint64(sizeVal) + } + + reports[providerDID] = report + } + } + } + + return reports, nil +} + +func parseUint64(v any) uint64 { + switch n := v.(type) { + case int: + return uint64(n) + case int32: + return uint64(n) + case int64: + return uint64(n) + case uint: + return uint64(n) + case uint32: + return uint64(n) + case uint64: + return n + case float32: + return uint64(n) + case float64: + return uint64(n) + default: + s := fmt.Sprintf("%v", v) + if val, err := strconv.ParseUint(s, 10, 64); err == nil { + return val + } + } + return 0 +} From b2c44b24ed694eb5295ef5ef9643925c53ee79ad Mon Sep 17 00:00:00 2001 From: Pranav Aggarwal Date: Sat, 21 Feb 2026 10:54:36 +0530 Subject: [PATCH 3/4] feat: add proof ls command --- cmd/proof/ls.go | 48 +++++++++++++++++++++++++++++++++++++++++++++++ cmd/proof/root.go | 1 + 2 files changed, 49 insertions(+) create mode 100644 cmd/proof/ls.go diff --git a/cmd/proof/ls.go b/cmd/proof/ls.go new file mode 100644 index 00000000..95b5fc24 --- /dev/null +++ b/cmd/proof/ls.go @@ -0,0 +1,48 @@ +package proof + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/storacha/guppy/internal/cmdutil" + "github.com/storacha/guppy/pkg/config" +) + +var lsCmd = &cobra.Command{ + Use: "ls", + Short: "List proofs of capabilities delegated to this agent.", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := config.Load[config.Config]() + if err != nil { + return err + } + + c := cmdutil.MustGetClient(cfg.Repo.Dir) + + proofs, err := c.Proofs() + if err != nil { + return fmt.Errorf("getting proofs: %w", err) + } + + if len(proofs) == 0 { + fmt.Println("No proofs found in the local agent store.") + return nil + } + + fmt.Printf("Found %d proof(s):\n", len(proofs)) + + for _, p := range proofs { + fmt.Printf("CID: %s\n", p.Link().String()) + fmt.Printf("Issuer: %s\n", p.Issuer().DID().String()) + fmt.Println("Capabilities:") + + for _, cap := range p.Capabilities() { + fmt.Printf(" - %v (with: %v)\n", cap.Can(), cap.With()) + } + } + + return nil + }, +} diff --git a/cmd/proof/root.go b/cmd/proof/root.go index 9b52f13c..f241fbd6 100644 --- a/cmd/proof/root.go +++ b/cmd/proof/root.go @@ -11,4 +11,5 @@ var Cmd = &cobra.Command{ func init() { Cmd.AddCommand(addCmd) + Cmd.AddCommand(lsCmd) } From 491120155a64f1a39358da3f519ea11f0e470b71 Mon Sep 17 00:00:00 2001 From: Pranav Aggarwal Date: Fri, 27 Feb 2026 01:08:27 +0530 Subject: [PATCH 4/4] feat: add delegation ls and revoke cmds with local cleanup --- cmd/delegation/create.go | 4 ++ cmd/delegation/ls.go | 57 ++++++++++++++++++++++ cmd/delegation/revoke.go | 37 ++++++++++++++ cmd/delegation/root.go | 2 + pkg/agentstore/fs.go | 21 ++++++++ pkg/agentstore/mem.go | 15 ++++++ pkg/agentstore/store_test.go | 33 +++++++++++++ pkg/agentstore/types.go | 1 + pkg/client/client.go | 5 ++ pkg/client/revoke.go | 94 ++++++++++++++++++++++++++++++++++++ 10 files changed, 269 insertions(+) create mode 100644 cmd/delegation/ls.go create mode 100644 cmd/delegation/revoke.go create mode 100644 pkg/client/revoke.go diff --git a/cmd/delegation/create.go b/cmd/delegation/create.go index 63ee5e95..b6037075 100644 --- a/cmd/delegation/create.go +++ b/cmd/delegation/create.go @@ -89,6 +89,10 @@ var createCmd = &cobra.Command{ return fmt.Errorf("creating delegation: %w", err) } + if err := c.AddProofs(dlg); err != nil { + return fmt.Errorf("saving delegation to local store: %w", err) + } + if createFlags.output != "" { data, err := io.ReadAll(delegation.Archive(dlg)) if err != nil { diff --git a/cmd/delegation/ls.go b/cmd/delegation/ls.go new file mode 100644 index 00000000..60ab3bac --- /dev/null +++ b/cmd/delegation/ls.go @@ -0,0 +1,57 @@ +package delegation + +import ( + "fmt" + + "github.com/spf13/cobra" + "github.com/storacha/guppy/internal/cmdutil" + "github.com/storacha/guppy/pkg/config" +) + +var lsCmd = &cobra.Command{ + Use: "ls", + Short: "List delegations created by this agent for others.", + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := config.Load[config.Config]() + if err != nil { + return err + } + c := cmdutil.MustGetClient(cfg.Repo.Dir) + + proofs, err := c.Proofs() + if err != nil { + return fmt.Errorf("getting proofs: %w", err) + } + + myDID := c.DID().String() + count := 0 + + for _, p := range proofs { + issuer := p.Issuer().DID().String() + audience := p.Audience().DID().String() + + if issuer == myDID && audience != myDID { + if count == 0 { + fmt.Println("Delegations created by this agent:") + } + count++ + + fmt.Printf("CID: %s\n", p.Link().String()) + fmt.Printf("Audience: %s\n", audience) + fmt.Printf("Capabilities:\n") + for _, cap := range p.Capabilities() { + fmt.Printf(" - %s (with: %s)\n", cap.Can(), cap.With()) + } + fmt.Println() + } + } + + if count == 0 { + fmt.Println("No external delegations created by this agent found.") + } else { + fmt.Printf("Total: %d delegation(s)\n", count) + } + + return nil + }, +} \ No newline at end of file diff --git a/cmd/delegation/revoke.go b/cmd/delegation/revoke.go new file mode 100644 index 00000000..d58629c7 --- /dev/null +++ b/cmd/delegation/revoke.go @@ -0,0 +1,37 @@ +package delegation + +import ( + "fmt" + + "github.com/ipfs/go-cid" + "github.com/spf13/cobra" + "github.com/storacha/guppy/internal/cmdutil" + "github.com/storacha/guppy/pkg/config" +) + +var revokeCmd = &cobra.Command{ + Use: "revoke ", + Short: "Revoke a delegation by CID.", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := config.Load[config.Config]() + if err != nil { + return err + } + c := cmdutil.MustGetClient(cfg.Repo.Dir) + + targetCid, err := cid.Parse(args[0]) + if err != nil { + return fmt.Errorf("invalid CID format: %w", err) + } + + fmt.Printf("Revoking delegation %s...\n", targetCid.String()) + + if err := c.Revoke(cmd.Context(), targetCid); err != nil { + return fmt.Errorf("failed to revoke delegation: %w", err) + } + + fmt.Println("Success! Delegation has been revoked.") + return nil + }, +} \ No newline at end of file diff --git a/cmd/delegation/root.go b/cmd/delegation/root.go index 8eeed58e..e785ff4c 100644 --- a/cmd/delegation/root.go +++ b/cmd/delegation/root.go @@ -9,4 +9,6 @@ var Cmd = &cobra.Command{ func init() { Cmd.AddCommand(createCmd) + Cmd.AddCommand(lsCmd) + Cmd.AddCommand(revokeCmd) } diff --git a/pkg/agentstore/fs.go b/pkg/agentstore/fs.go index 9f99fb54..1d6b8f78 100644 --- a/pkg/agentstore/fs.go +++ b/pkg/agentstore/fs.go @@ -9,6 +9,7 @@ import ( "github.com/storacha/go-ucanto/core/delegation" "github.com/storacha/go-ucanto/principal" ed25519 "github.com/storacha/go-ucanto/principal/ed25519/signer" + "github.com/ipfs/go-cid" ) var _ Store = (*FsStore)(nil) @@ -99,6 +100,26 @@ func (s *FsStore) AddDelegations(delegs ...delegation.Delegation) error { return writeToFile(s.path, data) } +func (s *FsStore) RemoveDelegation(id cid.Cid) error { + s.mu.Lock() + defer s.mu.Unlock() + + data, err := readFromFile(s.path) + if err != nil { + return err + } + + var updated []delegation.Delegation + for _, d := range data.Delegations { + if d.Link().String() != id.String() { + updated = append(updated, d) + } + } + + data.Delegations = updated + return writeToFile(s.path, data) +} + func (s *FsStore) Reset() error { s.mu.Lock() defer s.mu.Unlock() diff --git a/pkg/agentstore/mem.go b/pkg/agentstore/mem.go index 5d185837..0566f4da 100644 --- a/pkg/agentstore/mem.go +++ b/pkg/agentstore/mem.go @@ -6,6 +6,7 @@ import ( "github.com/storacha/go-ucanto/core/delegation" "github.com/storacha/go-ucanto/principal" ed25519 "github.com/storacha/go-ucanto/principal/ed25519/signer" + "github.com/ipfs/go-cid" ) var _ Store = (*MemStore)(nil) @@ -83,6 +84,20 @@ func (s *MemStore) delegations() ([]delegation.Delegation, error) { return s.data.Delegations, nil } +func (s *MemStore) RemoveDelegation(id cid.Cid) error { + s.mu.Lock() + defer s.mu.Unlock() + + var updated []delegation.Delegation + for _, d := range s.data.Delegations { + if d.Link().String() != id.String() { + updated = append(updated, d) + } + } + s.data.Delegations = updated + return nil +} + // Query returns delegations that match the given capability queries. // If no queries are provided, returns all non-expired delegations. // Delegations are filtered by: diff --git a/pkg/agentstore/store_test.go b/pkg/agentstore/store_test.go index 8c1a59f8..4988b140 100644 --- a/pkg/agentstore/store_test.go +++ b/pkg/agentstore/store_test.go @@ -13,6 +13,7 @@ import ( "github.com/storacha/go-ucanto/principal/ed25519/signer" "github.com/storacha/go-ucanto/ucan" "github.com/stretchr/testify/require" + "github.com/ipfs/go-cid" ) // delegationLinks extracts the CID links from a slice of delegations for comparison. @@ -44,6 +45,7 @@ func TestStore(t *testing.T) { t.Run("Principal", func(t *testing.T) { testPrincipal(t, newStore) }) t.Run("Delegations", func(t *testing.T) { testDelegations(t, newStore) }) t.Run("Query", func(t *testing.T) { testQuery(t, newStore) }) + t.Run("Remove", func(t *testing.T) { testRemove(t, newStore) }) t.Run("Reset", func(t *testing.T) { testReset(t, newStore) }) }) } @@ -464,6 +466,37 @@ func testQuery(t *testing.T, newStore func(t *testing.T) Store) { }) } +func testRemove(t *testing.T, newStore func(t *testing.T) Store) { + t.Run("removes a specific delegation", func(t *testing.T) { + s := newStore(t) + p, err := s.Principal() + require.NoError(t, err) + + del1 := testutil.Must(uploadcap.Add.Delegate( + p, p, p.DID().String(), + uploadcap.AddCaveats{Root: testutil.RandomCID(t), Shards: nil}, + ))(t) + + del2 := testutil.Must(uploadcap.Get.Delegate( + p, p, p.DID().String(), + uploadcap.GetCaveats{Root: testutil.RandomCID(t)}, + ))(t) + + err = s.AddDelegations(del1, del2) + require.NoError(t, err) + + targetCid, err := cid.Parse(del1.Link().String()) + require.NoError(t, err) + err = s.RemoveDelegation(targetCid) + require.NoError(t, err) + + delegs, err := s.Delegations() + require.NoError(t, err) + require.Len(t, delegs, 1, "should have exactly one delegation remaining") + require.Equal(t, del2.Link().String(), delegs[0].Link().String(), "the correct delegation should remain") + }) +} + func testReset(t *testing.T, newStore func(t *testing.T) Store) { t.Run("clears delegations but preserves principal", func(t *testing.T) { s := newStore(t) diff --git a/pkg/agentstore/types.go b/pkg/agentstore/types.go index 46dbb24d..8953c9ea 100644 --- a/pkg/agentstore/types.go +++ b/pkg/agentstore/types.go @@ -25,6 +25,7 @@ type Store interface { SetPrincipal(principal principal.Signer) error Delegations() ([]delegation.Delegation, error) AddDelegations(delegations ...delegation.Delegation) error + RemoveDelegation(id cid.Cid) error Reset() error Query(queries ...CapabilityQuery) ([]delegation.Delegation, error) } diff --git a/pkg/client/client.go b/pkg/client/client.go index 08378ea7..9537d32a 100644 --- a/pkg/client/client.go +++ b/pkg/client/client.go @@ -29,6 +29,7 @@ import ( "github.com/storacha/guppy/pkg/agentstore" "github.com/storacha/guppy/pkg/client/nodevalue" receiptclient "github.com/storacha/guppy/pkg/receipt" + "github.com/ipfs/go-cid" ) var ( @@ -153,6 +154,10 @@ func (c *Client) AddProofs(delegations ...delegation.Delegation) error { return c.store.AddDelegations(delegations...) } +func (c *Client) RemoveProof(id cid.Cid) error { + return c.store.RemoveDelegation(id) +} + // Reset clears all delegations from the store while preserving the principal. func (c *Client) Reset() error { return c.store.Reset() diff --git a/pkg/client/revoke.go b/pkg/client/revoke.go new file mode 100644 index 00000000..96d7d580 --- /dev/null +++ b/pkg/client/revoke.go @@ -0,0 +1,94 @@ +package client + +import ( + "context" + "fmt" + + "github.com/ipfs/go-cid" + "github.com/ipld/go-ipld-prime" + cidlink "github.com/ipld/go-ipld-prime/linking/cid" + uclient "github.com/storacha/go-ucanto/client" + "github.com/storacha/go-ucanto/core/delegation" + "github.com/storacha/go-ucanto/core/invocation" + "github.com/storacha/go-ucanto/core/receipt" + "github.com/storacha/go-ucanto/core/result" + "github.com/storacha/go-ucanto/ucan" + + "github.com/storacha/guppy/pkg/client/nodevalue" +) + +const RevokeCan = "ucan/revoke" + +type RevokeCaveat map[string]any + +func (r RevokeCaveat) ToIPLD() (ipld.Node, error) { + return nodevalue.FromAny(r) +} + +func (c *Client) Revoke(ctx context.Context, targetCid cid.Cid) error { + proofs, err := c.Proofs() + if err != nil { + return fmt.Errorf("querying local proofs: %w", err) + } + + var targetProof delegation.Delegation + for _, p := range proofs { + if p.Link().String() == targetCid.String() { + targetProof = p + break + } + } + + if targetProof == nil { + return fmt.Errorf("delegation %s not found locally. You must possess the delegation to revoke it", targetCid.String()) + } + + caveats := RevokeCaveat{ + "ucan": cidlink.Link{Cid: targetCid}, + } + + cap := ucan.NewCapability(RevokeCan, c.DID().String(), caveats) + + inv, err := invocation.Invoke( + c.Issuer(), + c.Connection().ID(), + cap, + delegation.WithProof(delegation.FromDelegation(targetProof)), + ) + if err != nil { + return fmt.Errorf("creating invocation: %w", err) + } + + resp, err := uclient.Execute(ctx, []invocation.Invocation{inv}, c.Connection()) + if err != nil { + return fmt.Errorf("executing invocation: %w", err) + } + + rcptLink, ok := resp.Get(inv.Link()) + if !ok { + return fmt.Errorf("receipt not found") + } + + anyRcpt, err := receipt.NewAnyReceiptReader().Read(rcptLink, resp.Blocks()) + if err != nil { + return fmt.Errorf("reading receipt: %w", err) + } + + _, errNode := result.Unwrap(anyRcpt.Out()) + + if errNode != nil { + val, _ := nodevalue.NodeValue(errNode) + if errMap, ok := val.(map[string]any); ok { + if msg, ok := errMap["message"].(string); ok && msg != "" { + return fmt.Errorf("server error: %s", msg) + } + } + return fmt.Errorf("server error: %v", val) + } + + if err := c.RemoveProof(targetCid); err != nil { + return fmt.Errorf("network revocation succeeded, but failed to remove from local store: %w", err) + } + + return nil +} \ No newline at end of file