diff --git a/go.mod b/go.mod index fc7b9ab..89f978b 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.26.4 require ( github.com/docker/docker v28.5.2+incompatible - github.com/fil-forge/libforge v0.0.0-20260619083649-eb26d871cda1 + github.com/fil-forge/libforge v0.0.0-20260623151745-4c28e5a78e9d github.com/fil-forge/ucantone v0.0.0-20260619013642-7985ec010b88 github.com/ipfs/go-cid v0.6.1 github.com/jackc/pgx/v5 v5.8.0 diff --git a/go.sum b/go.sum index 347cb4a..cbb4396 100644 --- a/go.sum +++ b/go.sum @@ -43,8 +43,8 @@ github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= -github.com/fil-forge/libforge v0.0.0-20260619083649-eb26d871cda1 h1:BZUTgenH/AawsAzH8xOx2tFyGXIhSacGiir4PwojX2M= -github.com/fil-forge/libforge v0.0.0-20260619083649-eb26d871cda1/go.mod h1:0kXihIQ4L2uZ00nR5XrZ/Y8Db7Ht/qQNuiWslwMJ95M= +github.com/fil-forge/libforge v0.0.0-20260623151745-4c28e5a78e9d h1:a2ZVWDpGQ+eOB4tcKMBK/ynHVYTuN+VvWtHxQokUM1Q= +github.com/fil-forge/libforge v0.0.0-20260623151745-4c28e5a78e9d/go.mod h1:0kXihIQ4L2uZ00nR5XrZ/Y8Db7Ht/qQNuiWslwMJ95M= github.com/fil-forge/ucantone v0.0.0-20260619013642-7985ec010b88 h1:N0gbL3Ik+XBYk4y/5BxTVymwbRGlxRXwC5eNWzi1bGI= github.com/fil-forge/ucantone v0.0.0-20260619013642-7985ec010b88/go.mod h1:rTIRXz4xErI4U+YlBU9ZvhlTbr4Hs5tJhVMwereVkSg= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= diff --git a/pkg/client/upload.go b/pkg/client/upload.go new file mode 100644 index 0000000..d5ab677 --- /dev/null +++ b/pkg/client/upload.go @@ -0,0 +1,126 @@ +package client + +import ( + "context" + "fmt" + "net/http" + "net/url" + + "github.com/fil-forge/hilt/pkg/lib/zapucan" + customercmds "github.com/fil-forge/libforge/commands/customer" + providercmds "github.com/fil-forge/libforge/commands/provider" + ucanlib "github.com/fil-forge/libforge/ucan" + "github.com/fil-forge/ucantone/client" + "github.com/fil-forge/ucantone/did" + "github.com/fil-forge/ucantone/execution" + "github.com/fil-forge/ucantone/ucan" + "github.com/fil-forge/ucantone/ucan/invocation" + "go.uber.org/zap" +) + +type UploadClientOption func(*UploadClientConfig) + +type UploadClientConfig struct { + httpClient *http.Client +} + +func WithHTTPClient(httpClient *http.Client) UploadClientOption { + return func(cfg *UploadClientConfig) { + cfg.httpClient = httpClient + } +} + +type UploadClient struct { + ServiceID did.DID + Proofs ucanlib.ProofStore + Executor execution.Executor + Logger *zap.Logger +} + +// NewUploadClient creates a new [UploadClient] for interacting with the upload +// service. The proofs parameter is used to provide proofs for UCAN invocations +// made by the client. +func NewUploadClient(serviceID did.DID, serviceURL url.URL, proofs ucanlib.ProofStore, logger *zap.Logger, opts ...UploadClientOption) (*UploadClient, error) { + cfg := &UploadClientConfig{} + for _, opt := range opts { + opt(cfg) + } + + var httpExecutor execution.Executor + var err error + if cfg.httpClient != nil { + httpExecutor, err = client.NewHTTP(&serviceURL, client.WithHTTPClient(cfg.httpClient)) + } else { + httpExecutor, err = client.NewHTTP(&serviceURL) + } + if err != nil { + return nil, fmt.Errorf("creating HTTP executor: %w", err) + } + + return &UploadClient{ + ServiceID: serviceID, + Proofs: proofs, + Executor: httpExecutor, + Logger: logger, + }, nil +} + +// RegisterCustomer registers a new customer with the upload service. +func (c *UploadClient) RegisterCustomer(ctx context.Context, issuer ucan.Issuer, id did.DID, product did.DID, details map[string]string) error { + proofs, proofLinks, err := c.Proofs.ProofChain(ctx, issuer.DID(), customercmds.Add.Command, c.ServiceID) + if err != nil { + return fmt.Errorf("getting proof chain: %w", err) + } + inv, err := customercmds.Add.Invoke( + issuer, + c.ServiceID, + &customercmds.AddArguments{ + Customer: id, + Product: product, + Details: details, + }, + invocation.WithAudience(c.ServiceID), + invocation.WithProofs(proofLinks...), + ) + if err != nil { + return fmt.Errorf("invoking register customer: %w", err) + } + log := zapucan.WithInvocation(c.Logger, inv) + log.Debug("executing invocation") + _, err = c.Executor.Execute(execution.NewRequest(ctx, inv, execution.WithDelegations(proofs...))) + if err != nil { + log.Error("failed to execute register customer invocation", zap.Error(err)) + return fmt.Errorf("executing register customer invocation: %w", err) + } + return nil +} + +// ProvisionSpace provisions a new space with the upload service. It returns the +// ID of the subscription that was set up. +func (c *UploadClient) ProvisionSpace(ctx context.Context, account ucan.Issuer, space did.DID) (string, error) { + inv, err := providercmds.Add.Invoke( + account, + account.DID(), + &providercmds.AddArguments{ + Provider: c.ServiceID, + Consumer: space, + }, + invocation.WithAudience(c.ServiceID), + ) + if err != nil { + return "", fmt.Errorf("invoking provision space: %w", err) + } + log := zapucan.WithInvocation(c.Logger, inv) + log.Debug("executing invocation") + res, err := c.Executor.Execute(execution.NewRequest(ctx, inv)) + if err != nil { + log.Error("failed to execute provision invocation", zap.Error(err)) + return "", fmt.Errorf("executing provision invocation: %w", err) + } + addOK, err := providercmds.Add.Unpack(res.Receipt()) + if err != nil { + log.Error("failed to unpack provision result", zap.Error(err)) + return "", fmt.Errorf("unpacking provision result: %w", err) + } + return addOK.ID, nil +} diff --git a/pkg/client/upload_test.go b/pkg/client/upload_test.go new file mode 100644 index 0000000..89fd312 --- /dev/null +++ b/pkg/client/upload_test.go @@ -0,0 +1,158 @@ +package client_test + +import ( + "context" + "errors" + "net/http" + "net/url" + "testing" + + "github.com/fil-forge/hilt/internal/testutil" + "github.com/fil-forge/hilt/pkg/client" + customercmds "github.com/fil-forge/libforge/commands/customer" + providercmds "github.com/fil-forge/libforge/commands/provider" + ucanlib "github.com/fil-forge/libforge/ucan" + "github.com/fil-forge/ucantone/binding" + "github.com/fil-forge/ucantone/did" + "github.com/fil-forge/ucantone/server" + "github.com/fil-forge/ucantone/ucan" + "github.com/fil-forge/ucantone/ucan/container" + "github.com/ipfs/go-cid" + "github.com/stretchr/testify/require" + "go.uber.org/zap" +) + +// newClient builds an UploadClient whose transport is the given in-process +// server, exercising NewUploadClient itself. +func newClient(t *testing.T, service ucan.Issuer, srv *server.HTTPServer, proofs ucanlib.ProofStore) *client.UploadClient { + t.Helper() + u, err := url.Parse("http://upload.test") + require.NoError(t, err) + c, err := client.NewUploadClient(service.DID(), *u, proofs, zap.NewNop(), + client.WithHTTPClient(&http.Client{Transport: srv})) + require.NoError(t, err) + return c +} + +// errProofStore is a ProofStore whose ProofChain always fails. +type errProofStore struct{ err error } + +func (e errProofStore) ProofChain(ctx context.Context, aud did.DID, cmd ucan.Command, sub did.DID) ([]ucan.Delegation, []cid.Cid, error) { + return nil, nil, e.err +} + +// errRoundTripper is an http.RoundTripper that always fails, forcing Execute to +// return an error. +type errRoundTripper struct{} + +func (errRoundTripper) RoundTrip(*http.Request) (*http.Response, error) { + return nil, errors.New("transport boom") +} + +func TestRegisterCustomer(t *testing.T) { + t.Run("success", func(t *testing.T) { + service := testutil.RandomIssuer(t) + alice := testutil.RandomIssuer(t) + customerDID := testutil.RandomDID(t) + product := testutil.RandomDID(t) + details := map[string]string{"name": "Acme"} + + // service delegates /customer/add to alice (root: subject == issuer). + dlg, err := customercmds.Add.Delegate(service, alice.DID(), service.DID()) + require.NoError(t, err) + proofs := ucanlib.NewContainerProofStore(container.New(container.WithDelegations(dlg))) + + var gotArgs *customercmds.AddArguments + var gotAud did.DID + srv := server.NewHTTP(service) + srv.Handle(customercmds.Add.Command, customercmds.Add.Handler( + func(req *binding.Request[*customercmds.AddArguments], res *binding.Response[*customercmds.AddOK]) error { + gotArgs = req.Task().Arguments() + gotAud = req.Invocation().Audience() + return res.SetSuccess(&customercmds.AddOK{}) + })) + + c := newClient(t, service, srv, proofs) + err = c.RegisterCustomer(t.Context(), alice, customerDID, product, details) + require.NoError(t, err) + + require.Equal(t, customerDID, gotArgs.Customer) + require.Equal(t, product, gotArgs.Product) + require.Equal(t, details, gotArgs.Details) + require.Equal(t, service.DID(), gotAud) + }) + + t.Run("proof chain error", func(t *testing.T) { + service := testutil.RandomIssuer(t) + alice := testutil.RandomIssuer(t) + srv := server.NewHTTP(service) + + c := newClient(t, service, srv, errProofStore{err: errors.New("boom")}) + err := c.RegisterCustomer(t.Context(), alice, testutil.RandomDID(t), testutil.RandomDID(t), nil) + require.Error(t, err) + require.Contains(t, err.Error(), "getting proof chain") + }) + + t.Run("execution error", func(t *testing.T) { + service := testutil.RandomIssuer(t) + alice := testutil.RandomIssuer(t) + + dlg, err := customercmds.Add.Delegate(service, alice.DID(), service.DID()) + require.NoError(t, err) + proofs := ucanlib.NewContainerProofStore(container.New(container.WithDelegations(dlg))) + + u, err := url.Parse("http://upload.test") + require.NoError(t, err) + c, err := client.NewUploadClient(service.DID(), *u, proofs, zap.NewNop(), + client.WithHTTPClient(&http.Client{Transport: errRoundTripper{}})) + require.NoError(t, err) + + err = c.RegisterCustomer(t.Context(), alice, testutil.RandomDID(t), testutil.RandomDID(t), nil) + require.Error(t, err) + }) +} + +func TestProvisionSpace(t *testing.T) { + t.Run("success", func(t *testing.T) { + service := testutil.RandomIssuer(t) + account := testutil.RandomIssuer(t) + space := testutil.RandomDID(t) + + var gotArgs *providercmds.AddArguments + var gotAud did.DID + srv := server.NewHTTP(service) + srv.Handle(providercmds.Add.Command, providercmds.Add.Handler( + func(req *binding.Request[*providercmds.AddArguments], res *binding.Response[*providercmds.AddOK]) error { + gotArgs = req.Task().Arguments() + gotAud = req.Invocation().Audience() + return res.SetSuccess(&providercmds.AddOK{ID: "sub-123"}) + })) + + // ProvisionSpace is self-issued and does not consult the proof store. + c := newClient(t, service, srv, nil) + id, err := c.ProvisionSpace(t.Context(), account, space) + require.NoError(t, err) + require.Equal(t, "sub-123", id) + + require.Equal(t, service.DID(), gotArgs.Provider) + require.Equal(t, space, gotArgs.Consumer) + require.Equal(t, service.DID(), gotAud) + }) + + t.Run("failure receipt", func(t *testing.T) { + service := testutil.RandomIssuer(t) + account := testutil.RandomIssuer(t) + space := testutil.RandomDID(t) + + srv := server.NewHTTP(service) + srv.Handle(providercmds.Add.Command, providercmds.Add.Handler( + func(req *binding.Request[*providercmds.AddArguments], res *binding.Response[*providercmds.AddOK]) error { + return res.SetFailure(errors.New("nope")) + })) + + c := newClient(t, service, srv, nil) + id, err := c.ProvisionSpace(t.Context(), account, space) + require.Error(t, err) + require.Empty(t, id) + }) +} diff --git a/pkg/lib/zapucan/invocation.go b/pkg/lib/zapucan/invocation.go new file mode 100644 index 0000000..20a7976 --- /dev/null +++ b/pkg/lib/zapucan/invocation.go @@ -0,0 +1,25 @@ +package zapucan + +import ( + "github.com/fil-forge/ucantone/ucan" + "go.uber.org/zap" +) + +func WithInvocation(logger *zap.Logger, inv ucan.Invocation) *zap.Logger { + fields := []zap.Field{ + zap.Stringer("issuer", inv.Issuer()), + zap.Stringer("subject", inv.Subject()), + zap.Stringer("command", inv.Command()), + zap.Object("arguments", RawMap(inv.ArgumentsBytes())), + } + if inv.Audience().Defined() { + fields = append(fields, zap.Stringer("audience", inv.Audience())) + } + if len(inv.MetadataBytes()) > 0 { + fields = append(fields, zap.Object("metadata", RawMap(inv.MetadataBytes()))) + } + if len(inv.Proofs()) > 0 { + fields = append(fields, zap.Stringers("proofs", inv.Proofs())) + } + return logger.With(zap.Dict("invocation", fields...)) +} diff --git a/pkg/lib/zapucan/raw_map.go b/pkg/lib/zapucan/raw_map.go new file mode 100644 index 0000000..195cadc --- /dev/null +++ b/pkg/lib/zapucan/raw_map.go @@ -0,0 +1,29 @@ +package zapucan + +import ( + "bytes" + + "github.com/fil-forge/ucantone/ipld/datamodel" + "go.uber.org/zap/zapcore" +) + +// RawMap is a [zapcore.ObjectMarshaler] that decodes the given bytes as a +// CBOR-encoded IPLD map and logs its keys and values. +type RawMap []byte + +func (rm RawMap) MarshalLogObject(enc zapcore.ObjectEncoder) error { + if len(rm) == 0 { + return nil + } + var m datamodel.Map + if err := m.UnmarshalCBOR(bytes.NewReader(rm)); err != nil { + return err + } + for k, v := range m { + err := enc.AddReflected(k, v) + if err != nil { + return err + } + } + return nil +} diff --git a/pkg/lib/zapucan/raw_map_test.go b/pkg/lib/zapucan/raw_map_test.go new file mode 100644 index 0000000..f42e170 --- /dev/null +++ b/pkg/lib/zapucan/raw_map_test.go @@ -0,0 +1,42 @@ +package zapucan_test + +import ( + "bytes" + "testing" + + "github.com/fil-forge/hilt/pkg/lib/zapucan" + "github.com/fil-forge/libforge/testutil" + "github.com/fil-forge/ucantone/ipld/datamodel" + "go.uber.org/zap" + "go.uber.org/zap/zaptest" +) + +func TestRawMap(t *testing.T) { + log := zaptest.NewLogger(t) + t.Run("logs raw map", func(t *testing.T) { + m := datamodel.Map{ + "foo": "bar", + "baz": 123, + "nested": datamodel.Map{ + "hello": "world", + }, + "array": []string{"a", "b", "c"}, + "mixedArray": []any{"a", 1, true, datamodel.Map{"key": "value"}}, + "nilly": nil, + "cid": testutil.RandomCID(t), + } + var buf bytes.Buffer + if err := m.MarshalCBOR(&buf); err != nil { + t.Fatalf("failed to marshal map: %v", err) + } + log.With(zap.Object("rawMap", zapucan.RawMap(buf.Bytes()))).Info("logging raw map") + }) + + t.Run("raw map empty bytes", func(t *testing.T) { + log.With(zap.Object("rawMap", zapucan.RawMap([]byte{}))).Info("empty bytes") + }) + + t.Run("raw map invalid bytes (non CBOR)", func(t *testing.T) { + log.With(zap.Object("rawMap", zapucan.RawMap([]byte{1, 2, 3}))).Info("invalid bytes") + }) +}