Skip to content
Open
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 @@ -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
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
126 changes: 126 additions & 0 deletions pkg/client/upload.go
Original file line number Diff line number Diff line change
@@ -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) {

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 "", fmt.Errorf("invoking provision space: %w", 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
}
Loading
Loading