Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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 @@ -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
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,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-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=
Expand Down
120 changes: 120 additions & 0 deletions pkg/client/upload.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
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(client *http.Client) UploadClientOption {
return func(cfg *UploadClientConfig) {
cfg.httpClient = client
}
}
Comment thread
alanshaw marked this conversation as resolved.
Outdated

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)
}

httpExecutor, err := client.NewHTTP(&serviceURL, client.WithHTTPClient(cfg.httpClient))
if err != nil {
return nil, fmt.Errorf("creating HTTP executor: %w", err)
}
Comment thread
alanshaw marked this conversation as resolved.
Outdated

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) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@alanshaw Am I correct in believing a Space is equivalent to a Bucket?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct 👍

inv, err := providercmds.Add.Invoke(
account,
account.DID(),
&providercmds.AddArguments{
Provider: c.ServiceID,
Consumer: space,
},
invocation.WithAudience(c.ServiceID),
)
if err != nil {
return "", err
}
Comment thread
alanshaw marked this conversation as resolved.
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
}
158 changes: 158 additions & 0 deletions pkg/client/upload_test.go
Original file line number Diff line number Diff line change
@@ -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)
})
}
25 changes: 25 additions & 0 deletions pkg/lib/zapucan/invocation.go
Original file line number Diff line number Diff line change
@@ -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...))
}
29 changes: 29 additions & 0 deletions pkg/lib/zapucan/raw_map.go
Original file line number Diff line number Diff line change
@@ -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
}
42 changes: 42 additions & 0 deletions pkg/lib/zapucan/raw_map_test.go
Original file line number Diff line number Diff line change
@@ -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")
})
}
Loading