Skip to content
Merged
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
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ require (
github.com/cenkalti/backoff/v5 v5.0.3
github.com/docker/docker v28.5.2+incompatible
github.com/fil-forge/libforge v0.0.0-20260807225550-3e6895b41be5
github.com/fil-forge/ucantone v0.0.0-20260827134420-25cf8340b9a1
github.com/fil-forge/ucantone v0.0.0-20260921155257-b59f1d1fcbb6
github.com/ipfs/go-cid v0.6.2
github.com/jackc/pgx/v5 v5.10.0
github.com/labstack/echo/v4 v4.15.0
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,8 @@ github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/fil-forge/libforge v0.0.0-20260807225550-3e6895b41be5 h1:Pu7Xk/LdaQyv1560k1MbL6CQUdWxZJIG9elEE0vkgLY=
github.com/fil-forge/libforge v0.0.0-20260807225550-3e6895b41be5/go.mod h1:aL/KcXZ0KVs1ymAYqIwQyCnl2p481InN+gYbRLNM8fI=
github.com/fil-forge/ucantone v0.0.0-20260827134420-25cf8340b9a1 h1:Tzy3lZ7+LAyVK4+qyWM97UMOtnXx05Wdn+rcTDLdBDE=
github.com/fil-forge/ucantone v0.0.0-20260827134420-25cf8340b9a1/go.mod h1:aX35jlUs3hnWAhigjO8z9kMPiYnkW3XOW8iVoJyz7cg=
github.com/fil-forge/ucantone v0.0.0-20260921155257-b59f1d1fcbb6 h1:NDhDcNPYVtnppbvBtUP9qEow5lsevM2NO+m4jOa+PWE=
github.com/fil-forge/ucantone v0.0.0-20260921155257-b59f1d1fcbb6/go.mod h1:aX35jlUs3hnWAhigjO8z9kMPiYnkW3XOW8iVoJyz7cg=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
Expand Down
49 changes: 48 additions & 1 deletion pkg/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import (
"github.com/fil-forge/ucantone/client"
"github.com/fil-forge/ucantone/did"
"github.com/fil-forge/ucantone/execution"
"github.com/fil-forge/ucantone/execution/batch"
"github.com/fil-forge/ucantone/ucan"
"github.com/fil-forge/ucantone/ucan/delegation"
"github.com/fil-forge/ucantone/ucan/invocation"
Expand All @@ -31,7 +32,7 @@ import (
type Client struct {
ServiceID did.DID
serviceURL url.URL
executor execution.Executor
executor *client.HTTPClient
httpClient *http.Client
}

Expand Down Expand Up @@ -119,6 +120,52 @@ func (c *Client) Publish(ctx context.Context, revoker ucan.Issuer, revoked ucan.
return nil
}

// PublishBatch submits one /ucan/revoke invocation per revoked delegation,
// all self-signed by revoker and sent in a single request. The revoker must be
// the issuer of every revoked delegation; no witness path is carried. Every
// invocation must succeed, otherwise the first failure is returned. A repeat of
// the same delegation is a duplicate the service records once.
func (c *Client) PublishBatch(ctx context.Context, revoker ucan.Issuer, revoked []ucan.Delegation) error {
if revoker == nil {
return errors.New("revoker is required")
}
if len(revoked) == 0 {
return nil
}
invocations := make([]ucan.Invocation, 0, len(revoked))
for _, d := range revoked {
if d == nil {
return errors.New("revoked delegation is required")
}
inv, err := ucancmd.Revoke.Invoke(
revoker,
revoker.DID(),
&ucancmd.RevokeArguments{Revoke: d.Link()},
invocation.WithAudience(c.ServiceID),
invocation.WithNoNonce(),
invocation.WithNoExpiration(),
)
if err != nil {
return fmt.Errorf("creating revoke invocation: %w", err)
}
invocations = append(invocations, inv)
}
response, err := c.executor.ExecuteBatch(batch.NewRequest(ctx, invocations, batch.WithDelegations(revoked...)))
if err != nil {
return fmt.Errorf("publishing revocations: %w", err)
}
for i, inv := range invocations {
receipt, ok := response.Receipt(inv.Task().Link())
if !ok {
return fmt.Errorf("no receipt for the revocation of %s", revoked[i].Link())
}
if _, err := ucancmd.Revoke.Unpack(receipt); err != nil {
return fmt.Errorf("unpacking revoke receipt: %w", err)
}
}
return nil
}

// Get retrieves the most recent revocation for delegation.
func (c *Client) Get(ctx context.Context, delegationCID cid.Cid) (store.RevocationRecord, error) {
request, err := http.NewRequestWithContext(ctx, http.MethodGet, c.endpoint("revocation", delegationCID.String()), nil)
Expand Down
64 changes: 64 additions & 0 deletions pkg/client/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package client
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"net/http"
Expand All @@ -22,6 +23,7 @@ import (
"github.com/fil-forge/ucantone/did"
"github.com/fil-forge/ucantone/multikey/ed25519"
"github.com/fil-forge/ucantone/server"
"github.com/fil-forge/ucantone/ucan"
"github.com/fil-forge/ucantone/ucan/command"
"github.com/fil-forge/ucantone/ucan/delegation"
"github.com/fil-forge/ucantone/ucan/invocation"
Expand Down Expand Up @@ -103,6 +105,68 @@ func TestPublish(t *testing.T) {
})
}

func TestPublishBatch(t *testing.T) {
service, err := ed25519.GenerateIssuer()
require.NoError(t, err)
alice, err := ed25519.GenerateIssuer()
require.NoError(t, err)
bob, err := ed25519.GenerateIssuer()
require.NoError(t, err)

cmd, err := command.Parse("/test/invoke")
require.NoError(t, err)
first, err := delegation.Delegate(alice, bob.DID(), alice.DID(), cmd)
require.NoError(t, err)
second, err := delegation.Delegate(alice, bob.DID(), alice.DID(), cmd)
require.NoError(t, err)

var requests int
var revoked []cid.Cid
srv := server.NewHTTP(service)
srv.Handle(ucancmd.Revoke.Command, ucancmd.Revoke.Handler(
func(req *binding.Request[*ucancmd.RevokeArguments], res *binding.Response[*ucancmd.RevokeOK]) error {
revoked = append(revoked, req.Task().Arguments().Revoke)
return res.SetSuccess(&ucancmd.RevokeOK{})
}))
transport := http.RoundTripper(roundTripFunc(func(r *http.Request) (*http.Response, error) {
requests++
return srv.RoundTrip(r)
}))

serviceURL, err := url.Parse("http://swarf.test")
require.NoError(t, err)
client, err := New(service.DID(), *serviceURL, WithHTTPClient(&http.Client{Transport: transport}))
require.NoError(t, err)

require.NoError(t, client.PublishBatch(context.Background(), alice, []ucan.Delegation{first, second}))
require.Equal(t, 1, requests, "one request carries every revocation")
require.ElementsMatch(t, []cid.Cid{first.Link(), second.Link()}, revoked)

t.Run("publishes nothing for no delegations", func(t *testing.T) {
requests = 0
require.NoError(t, client.PublishBatch(context.Background(), alice, nil))
require.Equal(t, 0, requests)
})

t.Run("fails when a revocation is refused", func(t *testing.T) {
refusing := server.NewHTTP(service)
refusing.Handle(ucancmd.Revoke.Command, ucancmd.Revoke.Handler(
func(req *binding.Request[*ucancmd.RevokeArguments], res *binding.Response[*ucancmd.RevokeOK]) error {
if req.Task().Arguments().Revoke == second.Link() {
return res.SetFailure(errors.New("refused"))
}
return res.SetSuccess(&ucancmd.RevokeOK{})
}))
client, err := New(service.DID(), *serviceURL, WithHTTPClient(&http.Client{Transport: refusing}))
require.NoError(t, err)
require.Error(t, client.PublishBatch(context.Background(), alice, []ucan.Delegation{first, second}))
})
}

type roundTripFunc func(*http.Request) (*http.Response, error)

func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }

func TestGetAndStream(t *testing.T) {
issuer, err := identity.New("", "")
require.NoError(t, err)
Expand Down
Loading