diff --git a/go.mod b/go.mod index 93ce250..bcb0843 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index efb1e40..4eaa309 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/pkg/client/client.go b/pkg/client/client.go index 815676b..59835d7 100644 --- a/pkg/client/client.go +++ b/pkg/client/client.go @@ -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" @@ -31,7 +32,7 @@ import ( type Client struct { ServiceID did.DID serviceURL url.URL - executor execution.Executor + executor *client.HTTPClient httpClient *http.Client } @@ -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) diff --git a/pkg/client/client_test.go b/pkg/client/client_test.go index fab67d6..35b6d92 100644 --- a/pkg/client/client_test.go +++ b/pkg/client/client_test.go @@ -3,6 +3,7 @@ package client import ( "bytes" "context" + "errors" "fmt" "io" "net/http" @@ -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" @@ -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)