diff --git a/README.md b/README.md index 98d6c4f..a903687 100644 --- a/README.md +++ b/README.md @@ -9,3 +9,5 @@ The Storacha upload service in Go. * The following dynamo tables have GSIs that do not exist in w3infra that need to be added: * `consumer` - `consumerV3` and `customerV2` * Using `cid.Cid` in new code over `ipld.Link` to ease transition to UCAN 1.0 when it comes. +* `retrievalAuth` is now an array of CIDs - an explicit delegation chain. +* `/upload/add` now takes an optional `index` CID, allowing us to track/remove indexes. diff --git a/cmd/client/admin/provider/deregister.go b/cmd/client/admin/provider/deregister.go index 85b28f5..87986c2 100644 --- a/cmd/client/admin/provider/deregister.go +++ b/cmd/client/admin/provider/deregister.go @@ -1,8 +1,8 @@ package provider import ( + "github.com/fil-forge/ucantone/did" "github.com/spf13/cobra" - "github.com/storacha/go-ucanto/did" "github.com/storacha/sprue/cmd/client/lib" ) @@ -15,12 +15,12 @@ var deregisterCmd = &cobra.Command{ } func doDeregister(cmd *cobra.Command, args []string) error { - c, _, _, id := lib.InitClient(cmd) + c, _, _, _ := lib.InitClient(cmd) providerID, err := did.Parse(args[0]) cobra.CheckErr(err) - _, err = c.AdminProviderDeregister(cmd.Context(), id.Signer, providerID) + _, err = c.AdminProviderDeregister(cmd.Context(), providerID) cobra.CheckErr(err) cmd.Println("Provider deregistered successfully") diff --git a/cmd/client/admin/provider/list.go b/cmd/client/admin/provider/list.go index 08b2cc5..006ff10 100644 --- a/cmd/client/admin/provider/list.go +++ b/cmd/client/admin/provider/list.go @@ -16,9 +16,9 @@ var listCmd = &cobra.Command{ } func doList(cmd *cobra.Command, args []string) error { - c, _, _, id := lib.InitClient(cmd) + c, _, _, _ := lib.InitClient(cmd) - res, err := c.AdminProviderList(cmd.Context(), id.Signer) + res, _, err := c.AdminProviderList(cmd.Context()) cobra.CheckErr(err) if len(res.Providers) == 0 { @@ -29,7 +29,7 @@ func doList(cmd *cobra.Command, args []string) error { table := lib.NewTable(cmd.OutOrStdout()) table.SetHeader([]string{"ID", "Weight", "Replication Weight", "URL"}) for _, p := range res.Providers { - table.Append([]string{p.ID.String(), fmt.Sprintf("%d", p.Weight), fmt.Sprintf("%d", p.ReplicationWeight), p.Endpoint}) + table.Append([]string{p.Provider.String(), fmt.Sprintf("%d", p.Weight), fmt.Sprintf("%d", p.ReplicationWeight), p.Endpoint}) } table.Render() diff --git a/cmd/client/admin/provider/register.go b/cmd/client/admin/provider/register.go index aa68698..fb7e27e 100644 --- a/cmd/client/admin/provider/register.go +++ b/cmd/client/admin/provider/register.go @@ -3,13 +3,13 @@ package provider import ( "net/url" + "github.com/fil-forge/ucantone/did" "github.com/spf13/cobra" - "github.com/storacha/go-ucanto/core/delegation" "github.com/storacha/sprue/cmd/client/lib" ) var registerCmd = &cobra.Command{ - Use: "register ", + Use: "register ", Aliases: []string{"add"}, Short: "Register a storage provider with the service", Args: cobra.ExactArgs(2), @@ -17,15 +17,15 @@ var registerCmd = &cobra.Command{ } func doRegister(cmd *cobra.Command, args []string) error { - c, _, _, id := lib.InitClient(cmd) + c, _, _, _ := lib.InitClient(cmd) - endpoint, err := url.Parse(args[0]) + id, err := did.Parse(args[0]) cobra.CheckErr(err) - proof, err := delegation.Parse(args[1]) + endpoint, err := url.Parse(args[1]) cobra.CheckErr(err) - _, err = c.AdminProviderRegister(cmd.Context(), id.Signer, endpoint.String(), proof) + _, err = c.AdminProviderRegister(cmd.Context(), id, endpoint.String()) cobra.CheckErr(err) cmd.Println("Provider registered successfully") diff --git a/cmd/client/admin/provider/weight/set.go b/cmd/client/admin/provider/weight/set.go index d6bde9a..d5882b4 100644 --- a/cmd/client/admin/provider/weight/set.go +++ b/cmd/client/admin/provider/weight/set.go @@ -3,8 +3,8 @@ package weight import ( "strconv" + "github.com/fil-forge/ucantone/did" "github.com/spf13/cobra" - "github.com/storacha/go-ucanto/did" "github.com/storacha/sprue/cmd/client/lib" ) @@ -16,7 +16,7 @@ var setCmd = &cobra.Command{ } func doSet(cmd *cobra.Command, args []string) error { - c, _, _, id := lib.InitClient(cmd) + c, _, _, _ := lib.InitClient(cmd) providerID, err := did.Parse(args[0]) cobra.CheckErr(err) @@ -27,7 +27,7 @@ func doSet(cmd *cobra.Command, args []string) error { replicationWeight, err := strconv.ParseInt(args[2], 10, 0) cobra.CheckErr(err) - _, err = c.AdminProviderWeightSet(cmd.Context(), id.Signer, providerID, int(weight), int(replicationWeight)) + _, err = c.AdminProviderWeightSet(cmd.Context(), providerID, int(weight), int(replicationWeight)) cobra.CheckErr(err) cmd.Println("Provider weight set successfully") diff --git a/cmd/client/lib/client.go b/cmd/client/lib/client.go index 368fea0..aa5a8de 100644 --- a/cmd/client/lib/client.go +++ b/cmd/client/lib/client.go @@ -2,6 +2,7 @@ package lib import ( "fmt" + "net/url" "github.com/spf13/cobra" "github.com/storacha/sprue/internal/config" @@ -25,10 +26,10 @@ func InitClient(cmd *cobra.Command) (*client.Client, *config.Config, *zap.Logger id, err := fx.NewIdentity(cfg, logger) cobra.CheckErr(err) - c, err := client.New( - id.Signer.DID(), - client.WithServiceURL(fmt.Sprintf("http://%s:%d", cfg.Server.Host, cfg.Server.Port)), - ) + endpoint, err := url.Parse(fmt.Sprintf("http://%s:%d", cfg.Server.Host, cfg.Server.Port)) + cobra.CheckErr(err) + + c, err := client.New(id.Signer.DID(), endpoint, id.Signer, logger) cobra.CheckErr(err) return c, cfg, logger, id } diff --git a/cmd/identity/parse.go b/cmd/identity/parse.go index 1483cab..3d8eacf 100644 --- a/cmd/identity/parse.go +++ b/cmd/identity/parse.go @@ -8,9 +8,9 @@ import ( "io" "os" + signer "github.com/fil-forge/ucantone/principal/ed25519" + verifier "github.com/fil-forge/ucantone/principal/ed25519/verifier" "github.com/spf13/cobra" - signer "github.com/storacha/go-ucanto/principal/ed25519/signer" - verifier "github.com/storacha/go-ucanto/principal/ed25519/verifier" ) var parseCmd = &cobra.Command{ @@ -61,7 +61,7 @@ var parseCmd = &cobra.Command{ return fmt.Errorf("PKCS#8 private key does not implement ed25519") } - key, err := signer.FromRaw(ed25519SK) + key, err := signer.FromRaw(ed25519SK.Seed()) if err != nil { return fmt.Errorf("decoding ed25519 private key: %w", err) } diff --git a/go.mod b/go.mod index 6715a82..01806ee 100644 --- a/go.mod +++ b/go.mod @@ -3,31 +3,32 @@ module github.com/storacha/sprue go 1.25.3 require ( + github.com/alanshaw/dag-json-gen v0.0.4 github.com/aws/aws-sdk-go-v2 v1.41.3 github.com/aws/aws-sdk-go-v2/config v1.32.11 github.com/aws/aws-sdk-go-v2/credentials v1.19.11 github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue v1.20.34 github.com/aws/aws-sdk-go-v2/service/dynamodb v1.56.1 github.com/aws/aws-sdk-go-v2/service/s3 v1.96.4 + github.com/docker/docker v28.5.2+incompatible + github.com/fil-forge/libforge v0.0.0-20260511094037-80a019ef76b6 + github.com/fil-forge/ucantone v0.0.0-20260507115308-bdd3b86f5b1b github.com/google/uuid v1.6.0 - github.com/ipfs/go-cid v0.6.0 - github.com/ipfs/go-log/v2 v2.9.0 + github.com/ipfs/go-cid v0.6.1 github.com/ipld/go-ipld-prime v0.21.1-0.20240917223228-6148356a4c2e github.com/labstack/echo/v4 v4.14.0 - github.com/multiformats/go-multiaddr v0.16.0 - github.com/multiformats/go-multibase v0.2.0 github.com/multiformats/go-multihash v0.2.3 github.com/olekukonko/tablewriter v0.0.5 github.com/spf13/cobra v1.10.2 github.com/spf13/viper v1.21.0 - github.com/storacha/go-libstoracha v0.7.5 - github.com/storacha/go-ucanto v0.7.2 github.com/stretchr/testify v1.11.1 github.com/testcontainers/testcontainers-go v0.41.0 github.com/testcontainers/testcontainers-go/modules/dynamodb v0.41.0 github.com/testcontainers/testcontainers-go/modules/minio v0.40.0 + github.com/whyrusleeping/cbor-gen v0.3.1 go.uber.org/fx v1.24.0 - go.uber.org/zap v1.27.0 + go.uber.org/zap v1.27.1 + golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da ) require ( @@ -61,49 +62,22 @@ require ( github.com/cpuguy83/dockercfg v0.3.2 // indirect github.com/creack/pty v1.1.24 // indirect github.com/davecgh/go-spew v1.1.1 // indirect - github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect github.com/distribution/reference v0.6.0 // indirect - github.com/docker/docker v28.5.2+incompatible github.com/docker/go-connections v0.6.0 // indirect github.com/docker/go-units v0.5.0 // indirect github.com/ebitengine/purego v0.10.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect - github.com/filecoin-project/go-data-segment v0.0.1 // indirect - github.com/filecoin-project/go-fil-commcid v0.2.0 // indirect - github.com/filecoin-project/go-fil-commp-hashhash v0.2.0 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-ole/go-ole v1.2.6 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/gobwas/glob v0.2.3 // indirect - github.com/gogo/protobuf v1.3.2 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect - github.com/hashicorp/golang-lru v1.0.2 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/ipfs/bbloom v0.0.4 // indirect - github.com/ipfs/go-block-format v0.2.0 // indirect - github.com/ipfs/go-blockservice v0.5.2 // indirect - github.com/ipfs/go-datastore v0.9.0 // indirect - github.com/ipfs/go-ipfs-blockstore v1.3.1 // indirect - github.com/ipfs/go-ipfs-ds-help v1.1.1 // indirect - github.com/ipfs/go-ipfs-exchange-interface v0.2.1 // indirect - github.com/ipfs/go-ipfs-util v0.0.3 // indirect - github.com/ipfs/go-ipld-cbor v0.1.0 // indirect - github.com/ipfs/go-ipld-format v0.6.0 // indirect - github.com/ipfs/go-ipld-legacy v0.2.1 // indirect - github.com/ipfs/go-log v1.0.5 // indirect - github.com/ipfs/go-merkledag v0.11.0 // indirect - github.com/ipfs/go-metrics-interface v0.0.1 // indirect - github.com/ipfs/go-verifcid v0.0.3 // indirect - github.com/ipld/go-car v0.6.2 // indirect - github.com/ipld/go-codec-dagpb v1.6.0 // indirect - github.com/ipni/go-libipni v0.6.18 // indirect github.com/klauspost/compress v1.18.2 // indirect github.com/klauspost/cpuid/v2 v2.2.10 // indirect github.com/labstack/gommon v0.4.2 // indirect - github.com/libp2p/go-buffer-pool v0.1.0 // indirect - github.com/libp2p/go-libp2p v0.41.1 // indirect github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect github.com/magiconair/properties v1.8.10 // indirect github.com/mattn/go-colorable v0.1.14 // indirect @@ -118,19 +92,15 @@ require ( github.com/moby/sys/userns v0.1.0 // indirect github.com/moby/term v0.5.2 // indirect github.com/morikuni/aec v1.0.0 // indirect - github.com/mr-tron/base58 v1.2.0 // indirect + github.com/mr-tron/base58 v1.3.0 // indirect github.com/multiformats/go-base32 v0.1.0 // indirect github.com/multiformats/go-base36 v0.2.0 // indirect + github.com/multiformats/go-multibase v0.3.0 // indirect github.com/multiformats/go-multicodec v0.9.1 // indirect github.com/multiformats/go-varint v0.1.0 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect - github.com/opentracing/opentracing-go v1.2.0 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect - github.com/pion/datachannel v1.6.0 // indirect - github.com/pion/logging v0.2.4 // indirect - github.com/pion/sctp v1.9.2 // indirect - github.com/pion/webrtc/v4 v4.2.9 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/polydawn/refmt v0.89.1-0.20231129105047-37766d95467a // indirect @@ -146,32 +116,29 @@ require ( github.com/subosito/gotenv v1.6.0 // indirect github.com/tklauser/go-sysconf v0.3.16 // indirect github.com/tklauser/numcpus v0.11.0 // indirect - github.com/ucan-wg/go-ucan v0.0.0-20240916120445-37f52863156c // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasttemplate v1.2.2 // indirect - github.com/whyrusleeping/cbor-gen v0.3.1 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect + gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect + gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect go.opentelemetry.io/otel v1.42.0 // indirect go.opentelemetry.io/otel/metric v1.42.0 // indirect go.opentelemetry.io/otel/trace v1.42.0 // indirect - go.uber.org/atomic v1.11.0 // indirect go.uber.org/dig v1.19.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/crypto v0.48.0 // indirect - golang.org/x/exp v0.0.0-20250218142911-aa4b98e5adaa // indirect - golang.org/x/net v0.51.0 // indirect - golang.org/x/sync v0.20.0 // indirect - golang.org/x/sys v0.41.0 // indirect - golang.org/x/text v0.34.0 // indirect + golang.org/x/crypto v0.50.0 // indirect + golang.org/x/net v0.52.0 // indirect + golang.org/x/sys v0.43.0 // indirect + golang.org/x/text v0.36.0 // indirect golang.org/x/time v0.14.0 // indirect - golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260316172706-e463d84ca32d // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260311181403-84a4fc48630c // indirect google.golang.org/grpc v1.79.2 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect lukechampine.com/blake3 v1.4.1 // indirect + pitr.ca/jsontokenizer v0.3.0 // indirect ) diff --git a/go.sum b/go.sum index 319eb39..3b5f1cb 100644 --- a/go.sum +++ b/go.sum @@ -47,6 +47,8 @@ github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03 github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/alanshaw/dag-json-gen v0.0.4 h1:qoryz04TVH6zu16NRFnzgolzQGaPfTvoIawv/F5rDoY= +github.com/alanshaw/dag-json-gen v0.0.4/go.mod h1:rXxWw0SItP9QjxpRMpkju66h0KumF7TPCtvHdOKS5lY= github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o= github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY= @@ -97,10 +99,6 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.41.8 h1:XQTQTF75vnug2TXS8m7CVJfC2nni github.com/aws/aws-sdk-go-v2/service/sts v1.41.8/go.mod h1:Xgx+PR1NUOjNmQY+tRMnouRp83JRM8pRMw/vCaVhPkI= github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng= github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= -github.com/benbjohnson/clock v1.3.5 h1:VvXlSJBzZpA/zum6Sj74hxwYI2DIxRWuNIoXAzHZz5o= -github.com/benbjohnson/clock v1.3.5/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= -github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= -github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= github.com/bketelsen/crypt v0.0.4/go.mod h1:aI6NrJ0pMGgvZKL1iVgXLnfIFJtfV+bKCoqOes/6LfM= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= @@ -136,17 +134,9 @@ github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsr github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= -github.com/cskr/pubsub v1.0.2 h1:vlOzMhl6PFn60gRlTQQsIfVwaPB/B/8MziK8FhEPt/0= -github.com/cskr/pubsub v1.0.2/go.mod h1:/8MzYXk/NJAz782G8RPkFzXTZVu63VotefPnR9TIRis= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davidlazar/go-crypto v0.0.0-20200604182044-b73af7476f6c h1:pFUpOrbxDR6AkioZ1ySsx5yxlDQZ8stG2b88gTPxgJU= -github.com/davidlazar/go-crypto v0.0.0-20200604182044-b73af7476f6c/go.mod h1:6UhI8N9EjYm1c2odKpFpAYeR8dsBeM7PtzQhRgxRr9U= -github.com/decred/dcrd/crypto/blake256 v1.1.0 h1:zPMNGQCm0g4QTY27fOCorQW7EryeQ/U0x++OzVrdms8= -github.com/decred/dcrd/crypto/blake256 v1.1.0/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo= -github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc= -github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM= @@ -169,16 +159,10 @@ github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7 github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= -github.com/filecoin-project/go-data-segment v0.0.1 h1:1wmDxOG4ubWQm3ZC1XI5nCon5qgSq7Ra3Rb6Dbu10Gs= -github.com/filecoin-project/go-data-segment v0.0.1/go.mod h1:H0/NKbsRxmRFBcLibmABv+yFNHdmtl5AyplYLnb0Zv4= -github.com/filecoin-project/go-fil-commcid v0.2.0 h1:B+5UX8XGgdg/XsdUpST4pEBviKkFOw+Fvl2bLhSKGpI= -github.com/filecoin-project/go-fil-commcid v0.2.0/go.mod h1:8yigf3JDIil+/WpqR5zoKyP0jBPCOGtEqq/K1CcMy9Q= -github.com/filecoin-project/go-fil-commp-hashhash v0.2.0 h1:HYIUugzjq78YvV3vC6rL95+SfC/aSTVSnZSZiDV5pCk= -github.com/filecoin-project/go-fil-commp-hashhash v0.2.0/go.mod h1:VH3fAFOru4yyWar4626IoS5+VGE8SfZiBODJLUigEo4= -github.com/flynn/noise v1.1.0 h1:KjPQoQCEFdZDiP03phOvGi11+SVVhBG2wOWAorLsstg= -github.com/flynn/noise v1.1.0/go.mod h1:xbMo+0i6+IGbYdJhF31t2eR1BIU0CYc12+BNAKwUTag= -github.com/francoispqt/gojay v1.2.13 h1:d2m3sFjloqoIUQU3TsHBgj6qg/BVGlTBeHDUmyJnXKk= -github.com/francoispqt/gojay v1.2.13/go.mod h1:ehT5mTG4ua4581f1++1WLG0vPdaA9HaiDsoyrBGkyDY= +github.com/fil-forge/libforge v0.0.0-20260511094037-80a019ef76b6 h1:rWqWgJea/rKIIv22RLzMJFIzhc9Je9y8n9P3eM84Tz4= +github.com/fil-forge/libforge v0.0.0-20260511094037-80a019ef76b6/go.mod h1:IdNOBIQeH59dG99FnLmqrwrvaJ4Akm4IPUng8DeqFig= +github.com/fil-forge/ucantone v0.0.0-20260507115308-bdd3b86f5b1b h1:8tvw5e7S1ntUnm0v/OTWLZelbJWQcho/WZI+ttCPv+A= +github.com/fil-forge/ucantone v0.0.0-20260507115308-bdd3b86f5b1b/go.mod h1:vqgVEsy6LEEsY24Zyjxem0vSofj1XTIx29GbV635f+I= 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.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= @@ -195,15 +179,12 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= -github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= -github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/go-yaml/yaml v2.1.0+incompatible/go.mod h1:w2MrLa16VYP0jy6N7M5kHaCkaLENm+P+Tv+MfurjSw0= github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= -github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= @@ -251,8 +232,6 @@ github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF8= -github.com/google/gopacket v1.1.19/go.mod h1:iJ8V8n6KS+z2U1A8pUwu8bW5SyEMkXJB8Yo/Vo+TKTo= github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= @@ -267,8 +246,6 @@ github.com/google/pprof v0.0.0-20201023163331-3e6fc7fc9c4c/go.mod h1:kpwsk12EmLe github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/pprof v0.0.0-20210122040257-d980be63207e/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/pprof v0.0.0-20210226084205-cbba55b83ad5/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= -github.com/google/pprof v0.0.0-20250208200701-d0013a598941 h1:43XjGa6toxLpeksjcxs1jIoIyr+vUfOqY2c6HB4bpoc= -github.com/google/pprof v0.0.0-20250208200701-d0013a598941/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -296,87 +273,20 @@ github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/b github.com/hashicorp/go.net v0.0.1/go.mod h1:hjKkEWcCURg++eb33jQU7oqQcI9XDCnUzHA0oac0k90= github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= -github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c= -github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64= github.com/hashicorp/mdns v1.0.0/go.mod h1:tL+uN++7HEJ6SQLQ2/p+z2pH24WQKWjBPkE0mNTz8vQ= github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2pPBoIllUwCN7I= github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc= -github.com/huin/goupnp v1.3.0 h1:UvLUlWDNpoUdYzb2TCn+MuTWtcjXKSza2n6CBdQ0xXc= -github.com/huin/goupnp v1.3.0/go.mod h1:gnGPsThkYa7bFi/KWmEysQRf48l2dvR5bxr2OFckNX8= github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/ipfs/bbloom v0.0.4 h1:Gi+8EGJ2y5qiD5FbsbpX/TMNcJw8gSqr7eyjHa4Fhvs= -github.com/ipfs/bbloom v0.0.4/go.mod h1:cS9YprKXpoZ9lT0n/Mw/a6/aFV6DTjTLYHeA+gyqMG0= -github.com/ipfs/go-bitswap v0.11.0 h1:j1WVvhDX1yhG32NTC9xfxnqycqYIlhzEzLXG/cU1HyQ= -github.com/ipfs/go-bitswap v0.11.0/go.mod h1:05aE8H3XOU+LXpTedeAS0OZpcO1WFsj5niYQH9a1Tmk= -github.com/ipfs/go-block-format v0.2.0 h1:ZqrkxBA2ICbDRbK8KJs/u0O3dlp6gmAuuXUJNiW1Ycs= -github.com/ipfs/go-block-format v0.2.0/go.mod h1:+jpL11nFx5A/SPpsoBn6Bzkra/zaArfSmsknbPMYgzM= -github.com/ipfs/go-blockservice v0.5.2 h1:in9Bc+QcXwd1apOVM7Un9t8tixPKdaHQFdLSUM1Xgk8= -github.com/ipfs/go-blockservice v0.5.2/go.mod h1:VpMblFEqG67A/H2sHKAemeH9vlURVavlysbdUI632yk= -github.com/ipfs/go-cid v0.6.0 h1:DlOReBV1xhHBhhfy/gBNNTSyfOM6rLiIx9J7A4DGf30= -github.com/ipfs/go-cid v0.6.0/go.mod h1:NC4kS1LZjzfhK40UGmpXv5/qD2kcMzACYJNntCUiDhQ= -github.com/ipfs/go-datastore v0.9.0 h1:WocriPOayqalEsueHv6SdD4nPVl4rYMfYGLD4bqCZ+w= -github.com/ipfs/go-datastore v0.9.0/go.mod h1:uT77w/XEGrvJWwHgdrMr8bqCN6ZTW9gzmi+3uK+ouHg= -github.com/ipfs/go-detect-race v0.0.1 h1:qX/xay2W3E4Q1U7d9lNs1sU9nvguX0a7319XbyQ6cOk= -github.com/ipfs/go-detect-race v0.0.1/go.mod h1:8BNT7shDZPo99Q74BpGMK+4D8Mn4j46UU0LZ723meps= -github.com/ipfs/go-ipfs-blockstore v1.3.1 h1:cEI9ci7V0sRNivqaOr0elDsamxXFxJMMMy7PTTDQNsQ= -github.com/ipfs/go-ipfs-blockstore v1.3.1/go.mod h1:KgtZyc9fq+P2xJUiCAzbRdhhqJHvsw8u2Dlqy2MyRTE= -github.com/ipfs/go-ipfs-blocksutil v0.0.1 h1:Eh/H4pc1hsvhzsQoMEP3Bke/aW5P5rVM1IWFJMcGIPQ= -github.com/ipfs/go-ipfs-blocksutil v0.0.1/go.mod h1:Yq4M86uIOmxmGPUHv/uI7uKqZNtLb449gwKqXjIsnRk= -github.com/ipfs/go-ipfs-delay v0.0.1 h1:r/UXYyRcddO6thwOnhiznIAiSvxMECGgtv35Xs1IeRQ= -github.com/ipfs/go-ipfs-delay v0.0.1/go.mod h1:8SP1YXK1M1kXuc4KJZINY3TQQ03J2rwBG9QfXmbRPrw= -github.com/ipfs/go-ipfs-ds-help v1.1.1 h1:B5UJOH52IbcfS56+Ul+sv8jnIV10lbjLF5eOO0C66Nw= -github.com/ipfs/go-ipfs-ds-help v1.1.1/go.mod h1:75vrVCkSdSFidJscs8n4W+77AtTpCIAdDGAwjitJMIo= -github.com/ipfs/go-ipfs-exchange-interface v0.2.1 h1:jMzo2VhLKSHbVe+mHNzYgs95n0+t0Q69GQ5WhRDZV/s= -github.com/ipfs/go-ipfs-exchange-interface v0.2.1/go.mod h1:MUsYn6rKbG6CTtsDp+lKJPmVt3ZrCViNyH3rfPGsZ2E= -github.com/ipfs/go-ipfs-exchange-offline v0.3.0 h1:c/Dg8GDPzixGd0MC8Jh6mjOwU57uYokgWRFidfvEkuA= -github.com/ipfs/go-ipfs-exchange-offline v0.3.0/go.mod h1:MOdJ9DChbb5u37M1IcbrRB02e++Z7521fMxqCNRrz9s= -github.com/ipfs/go-ipfs-pq v0.0.2 h1:e1vOOW6MuOwG2lqxcLA+wEn93i/9laCY8sXAw76jFOY= -github.com/ipfs/go-ipfs-pq v0.0.2/go.mod h1:LWIqQpqfRG3fNc5XsnIhz/wQ2XXGyugQwls7BgUmUfY= -github.com/ipfs/go-ipfs-routing v0.3.0 h1:9W/W3N+g+y4ZDeffSgqhgo7BsBSJwPMcyssET9OWevc= -github.com/ipfs/go-ipfs-routing v0.3.0/go.mod h1:dKqtTFIql7e1zYsEuWLyuOU+E0WJWW8JjbTPLParDWo= -github.com/ipfs/go-ipfs-util v0.0.3 h1:2RFdGez6bu2ZlZdI+rWfIdbQb1KudQp3VGwPtdNCmE0= -github.com/ipfs/go-ipfs-util v0.0.3/go.mod h1:LHzG1a0Ig4G+iZ26UUOMjHd+lfM84LZCrn17xAKWBvs= -github.com/ipfs/go-ipld-cbor v0.1.0 h1:dx0nS0kILVivGhfWuB6dUpMa/LAwElHPw1yOGYopoYs= -github.com/ipfs/go-ipld-cbor v0.1.0/go.mod h1:U2aYlmVrJr2wsUBU67K4KgepApSZddGRDWBYR0H4sCk= -github.com/ipfs/go-ipld-format v0.6.0 h1:VEJlA2kQ3LqFSIm5Vu6eIlSxD/Ze90xtc4Meten1F5U= -github.com/ipfs/go-ipld-format v0.6.0/go.mod h1:g4QVMTn3marU3qXchwjpKPKgJv+zF+OlaKMyhJ4LHPg= -github.com/ipfs/go-ipld-legacy v0.2.1 h1:mDFtrBpmU7b//LzLSypVrXsD8QxkEWxu5qVxN99/+tk= -github.com/ipfs/go-ipld-legacy v0.2.1/go.mod h1:782MOUghNzMO2DER0FlBR94mllfdCJCkTtDtPM51otM= -github.com/ipfs/go-log v1.0.5 h1:2dOuUCB1Z7uoczMWgAyDck5JLb72zHzrMnGnCNNbvY8= -github.com/ipfs/go-log v1.0.5/go.mod h1:j0b8ZoR+7+R99LD9jZ6+AJsrzkPbSXbZfGakb5JPtIo= -github.com/ipfs/go-log/v2 v2.1.3/go.mod h1:/8d0SH3Su5Ooc31QlL1WysJhvyOTDCjcCZ9Axpmri6g= -github.com/ipfs/go-log/v2 v2.9.0 h1:l4b06AwVXwldIzbVPZy5z7sKp9lHFTX0KWfTBCtHaOk= -github.com/ipfs/go-log/v2 v2.9.0/go.mod h1:UhIYAwMV7Nb4ZmihUxfIRM2Istw/y9cAk3xaK+4Zs2c= -github.com/ipfs/go-merkledag v0.11.0 h1:DgzwK5hprESOzS4O1t/wi6JDpyVQdvm9Bs59N/jqfBY= -github.com/ipfs/go-merkledag v0.11.0/go.mod h1:Q4f/1ezvBiJV0YCIXvt51W/9/kqJGH4I1LsA7+djsM4= -github.com/ipfs/go-metrics-interface v0.0.1 h1:j+cpbjYvu4R8zbleSs36gvB7jR+wsL2fGD6n0jO4kdg= -github.com/ipfs/go-metrics-interface v0.0.1/go.mod h1:6s6euYU4zowdslK0GKHmqaIZ3j/b/tL7HTWtJ4VPgWY= -github.com/ipfs/go-peertaskqueue v0.8.0 h1:JyNO144tfu9bx6Hpo119zvbEL9iQ760FHOiJYsUjqaU= -github.com/ipfs/go-peertaskqueue v0.8.0/go.mod h1:cz8hEnnARq4Du5TGqiWKgMr/BOSQ5XOgMOh1K5YYKKM= -github.com/ipfs/go-test v0.2.1 h1:/D/a8xZ2JzkYqcVcV/7HYlCnc7bv/pKHQiX5TdClkPE= -github.com/ipfs/go-test v0.2.1/go.mod h1:dzu+KB9cmWjuJnXFDYJwC25T3j1GcN57byN+ixmK39M= -github.com/ipfs/go-verifcid v0.0.3 h1:gmRKccqhWDocCRkC+a59g5QW7uJw5bpX9HWBevXa0zs= -github.com/ipfs/go-verifcid v0.0.3/go.mod h1:gcCtGniVzelKrbk9ooUSX/pM3xlH73fZZJDzQJRvOUw= -github.com/ipld/go-car v0.6.2 h1:Hlnl3Awgnq8icK+ze3iRghk805lu8YNq3wlREDTF2qc= -github.com/ipld/go-car v0.6.2/go.mod h1:oEGXdwp6bmxJCZ+rARSkDliTeYnVzv3++eXajZ+Bmr8= -github.com/ipld/go-codec-dagpb v1.6.0 h1:9nYazfyu9B1p3NAgfVdpRco3Fs2nFC72DqVsMj6rOcc= -github.com/ipld/go-codec-dagpb v1.6.0/go.mod h1:ANzFhfP2uMJxRBr8CE+WQWs5UsNa0pYtmKZ+agnUw9s= +github.com/ipfs/go-cid v0.6.1 h1:T5TnNb08+ueovG76Z5gx1L4Y7QOaGTXHg1F6raWFxIc= +github.com/ipfs/go-cid v0.6.1/go.mod h1:zrY0SwOhjrrIdfPQ/kf+k1sXyJ0QE7cMxfCployLBs0= github.com/ipld/go-ipld-prime v0.21.1-0.20240917223228-6148356a4c2e h1:0Anxx6pMS8U/qjTLVxPhpTYuuDMssHDtUEvzIz2Skw4= github.com/ipld/go-ipld-prime v0.21.1-0.20240917223228-6148356a4c2e/go.mod h1:LN+1Tx6867lbDCmf8bErp1TNw3Kh9eY2n0eJ+whRx38= -github.com/ipni/go-libipni v0.6.18 h1:x8X6y0QoMmSKtwRlczWdWEYedoLUGCEek2TttfDKPk4= -github.com/ipni/go-libipni v0.6.18/go.mod h1:qUObcCVXMx3byEGn/g2alGlsqY79tTZBzWoNPCwYFOE= -github.com/jackpal/go-nat-pmp v1.0.2 h1:KzKSgb7qkJvOUTqYl9/Hg/me3pWgBmERKrTGD7BdWus= -github.com/jackpal/go-nat-pmp v1.0.2/go.mod h1:QPH045xvCAeXUZOxsnwmrtiCoxIr9eob+4orBN1SBKc= -github.com/jbenet/go-temp-err-catcher v0.1.0 h1:zpb3ZH6wIE8Shj2sKS+khgRvf7T7RABoLk/+KKHggpk= -github.com/jbenet/go-temp-err-catcher v0.1.0/go.mod h1:0kJRvmDZXNMIiJirNPEYfhpPwbGVtZVWC34vc5WLsDk= -github.com/jbenet/goprocess v0.1.4 h1:DRGOFReOMqqDNXwW70QkacFW0YN9QnwLV0Vqk+3oU0o= -github.com/jbenet/goprocess v0.1.4/go.mod h1:5yspPrukOVuOLORacaBi858NqyClJPQxYZlqdZVfqY4= github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= @@ -390,8 +300,6 @@ github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uq github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= -github.com/koron/go-ssdp v0.0.5 h1:E1iSMxIs4WqxTbIBLtmNBeOOC+1sCIXQeqTWVnpmwhk= -github.com/koron/go-ssdp v0.0.5/go.mod h1:Qm59B7hpKpDqfyRNWRNr00jGwLdXjDyZh6y7rH6VS0w= github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -404,25 +312,13 @@ github.com/labstack/echo/v4 v4.14.0 h1:+tiMrDLxwv6u0oKtD03mv+V1vXXB3wCqPHJqPuIe+ github.com/labstack/echo/v4 v4.14.0/go.mod h1:xmw1clThob0BSVRX1CRQkGQ/vjwcpOMjQZSZa9fKA/c= github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= -github.com/libp2p/go-buffer-pool v0.1.0 h1:oK4mSFcQz7cTQIfqbe4MIj9gLW+mnanjyFtc6cdF0Y8= -github.com/libp2p/go-buffer-pool v0.1.0/go.mod h1:N+vh8gMqimBzdKkSMVuydVDq+UV5QTWy5HSiZacSbPg= -github.com/libp2p/go-libp2p v0.41.1 h1:8ecNQVT5ev/jqALTvisSJeVNvXYJyK4NhQx1nNRXQZE= -github.com/libp2p/go-libp2p v0.41.1/go.mod h1:DcGTovJzQl/I7HMrby5ZRjeD0kQkGiy+9w6aEkSZpRI= -github.com/libp2p/go-libp2p-asn-util v0.4.1 h1:xqL7++IKD9TBFMgnLPZR6/6iYhawHKHl950SO9L6n94= -github.com/libp2p/go-libp2p-asn-util v0.4.1/go.mod h1:d/NI6XZ9qxw67b4e+NgpQexCIiFYJjErASrYW4PFDN8= -github.com/libp2p/go-libp2p-record v0.2.0 h1:oiNUOCWno2BFuxt3my4i1frNrt7PerzB3queqa1NkQ0= -github.com/libp2p/go-libp2p-record v0.2.0/go.mod h1:I+3zMkvvg5m2OcSdoL0KPljyJyvNDFGKX7QdlpYUcwk= -github.com/libp2p/go-libp2p-testing v0.12.0 h1:EPvBb4kKMWO29qP4mZGyhVzUyR25dvfUIK5WDu6iPUA= -github.com/libp2p/go-libp2p-testing v0.12.0/go.mod h1:KcGDRXyN7sQCllucn1cOOS+Dmm7ujhfEyXQL5lvkcPg= -github.com/libp2p/go-msgio v0.3.0 h1:mf3Z8B1xcFN314sWX+2vOTShIE0Mmn2TXn3YCUQGNj0= -github.com/libp2p/go-msgio v0.3.0/go.mod h1:nyRM819GmVaF9LX3l03RMh10QdOroF++NBbxAb0mmDM= -github.com/libp2p/go-netroute v0.2.2 h1:Dejd8cQ47Qx2kRABg6lPwknU7+nBnFRpko45/fFPuZ8= -github.com/libp2p/go-netroute v0.2.2/go.mod h1:Rntq6jUAH0l9Gg17w5bFGhcC9a+vk4KNXs6s7IljKYE= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= github.com/magiconair/properties v1.8.5/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60= github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/matryer/is v1.4.0 h1:sosSmIWwkYITGrxZ25ULNDeKiMNzFSr4V/eqBQP0PeE= +github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= @@ -472,83 +368,32 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= -github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o= -github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc= +github.com/mr-tron/base58 v1.3.0 h1:K6Y13R2h+dku0wOqKtecgRnBUBPrZzLZy5aIj8lCcJI= +github.com/mr-tron/base58 v1.3.0/go.mod h1:2BuubE67DCSWwVfx37JWNG8emOC0sHEU4/HpcYgCLX8= github.com/multiformats/go-base32 v0.1.0 h1:pVx9xoSPqEIQG8o+UbAe7DNi51oej1NtK+aGkbLYxPE= github.com/multiformats/go-base32 v0.1.0/go.mod h1:Kj3tFY6zNr+ABYMqeUNeGvkIC/UYgtWibDcT0rExnbI= github.com/multiformats/go-base36 v0.2.0 h1:lFsAbNOGeKtuKozrtBsAkSVhv1p9D0/qedU9rQyccr0= github.com/multiformats/go-base36 v0.2.0/go.mod h1:qvnKE++v+2MWCfePClUEjE78Z7P2a1UV0xHgWc0hkp4= -github.com/multiformats/go-multiaddr v0.16.0 h1:oGWEVKioVQcdIOBlYM8BH1rZDWOGJSqr9/BKl6zQ4qc= -github.com/multiformats/go-multiaddr v0.16.0/go.mod h1:JSVUmXDjsVFiW7RjIFMP7+Ev+h1DTbiJgVeTV/tcmP0= -github.com/multiformats/go-multiaddr-fmt v0.1.0 h1:WLEFClPycPkp4fnIzoFoV9FVd49/eQsuaL3/CWe167E= -github.com/multiformats/go-multiaddr-fmt v0.1.0/go.mod h1:hGtDIW4PU4BqJ50gW2quDuPVjyWNZxToGUh/HwTZYJo= -github.com/multiformats/go-multibase v0.2.0 h1:isdYCVLvksgWlMW9OZRYJEa9pZETFivncJHmHnnd87g= -github.com/multiformats/go-multibase v0.2.0/go.mod h1:bFBZX4lKCA/2lyOFSAoKH5SS6oPyjtnzK/XTFDPkNuk= +github.com/multiformats/go-multibase v0.3.0 h1:8helZD2+4Db7NNWFiktk2NePbF0boolBe6bDQvM4r68= +github.com/multiformats/go-multibase v0.3.0/go.mod h1:MoBLQPCkRTOL3eveIPO81860j2AQY8JwcnNlRkGRUfI= github.com/multiformats/go-multicodec v0.9.1 h1:x/Fuxr7ZuR4jJV4Os5g444F7xC4XmyUaT/FWtE+9Zjo= github.com/multiformats/go-multicodec v0.9.1/go.mod h1:LLWNMtyV5ithSBUo3vFIMaeDy+h3EbkMTek1m+Fybbo= github.com/multiformats/go-multihash v0.2.3 h1:7Lyc8XfX/IY2jWb/gI7JP+o7JEq9hOa7BFvVU9RSh+U= github.com/multiformats/go-multihash v0.2.3/go.mod h1:dXgKXCXjBzdscBLk9JkjINiEsCKRVch90MdaGiKsvSM= -github.com/multiformats/go-multistream v0.6.0 h1:ZaHKbsL404720283o4c/IHQXiS6gb8qAN5EIJ4PN5EA= -github.com/multiformats/go-multistream v0.6.0/go.mod h1:MOyoG5otO24cHIg8kf9QW2/NozURlkP/rvi2FQJyCPg= github.com/multiformats/go-varint v0.1.0 h1:i2wqFp4sdl3IcIxfAonHQV9qU5OsZ4Ts9IOoETFs5dI= github.com/multiformats/go-varint v0.1.0/go.mod h1:5KVAVXegtfmNQQm/lCY+ATvDzvJJhSkUlGQV9wgObdI= -github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= -github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/neelance/astrewrite v0.0.0-20160511093645-99348263ae86/go.mod h1:kHJEU3ofeGjhHklVoIGuVj85JJwZ6kWPaJwCIxgnFmo= github.com/neelance/sourcemap v0.0.0-20200213170602-2833bce08e4c/go.mod h1:Qr6/a/Q4r9LP1IltGz7tA7iOK1WonHEYhu1HRBA7ZiM= github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= -github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= -github.com/onsi/ginkgo/v2 v2.22.2 h1:/3X8Panh8/WwhU/3Ssa6rCKqPLuAkVY2I0RoyDLySlU= -github.com/onsi/ginkgo/v2 v2.22.2/go.mod h1:oeMosUL+8LtarXBHu/c0bx2D/K9zyQ6uX3cTyztHwsk= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= -github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs= -github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc= github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= github.com/pelletier/go-toml v1.9.3/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= -github.com/pion/datachannel v1.6.0 h1:XecBlj+cvsxhAMZWFfFcPyUaDZtd7IJvrXqlXD/53i0= -github.com/pion/datachannel v1.6.0/go.mod h1:ur+wzYF8mWdC+Mkis5Thosk+u/VOL287apDNEbFpsIk= -github.com/pion/dtls/v2 v2.2.12 h1:KP7H5/c1EiVAAKUmXyCzPiQe5+bCJrpOeKg/L05dunk= -github.com/pion/dtls/v2 v2.2.12/go.mod h1:d9SYc9fch0CqK90mRk1dC7AkzzpwJj6u2GU3u+9pqFE= -github.com/pion/dtls/v3 v3.1.2 h1:gqEdOUXLtCGW+afsBLO0LtDD8GnuBBjEy6HRtyofZTc= -github.com/pion/dtls/v3 v3.1.2/go.mod h1:Hw/igcX4pdY69z1Hgv5x7wJFrUkdgHwAn/Q/uo7YHRo= -github.com/pion/ice/v4 v4.2.1 h1:XPRYXaLiFq3LFDG7a7bMrmr3mFr27G/gtXN3v/TVfxY= -github.com/pion/ice/v4 v4.2.1/go.mod h1:2quLV1S5v1tAx3VvAJaH//KGitRXvo4RKlX6D3tnN+c= -github.com/pion/interceptor v0.1.44 h1:sNlZwM8dWXU9JQAkJh8xrarC0Etn8Oolcniukmuy0/I= -github.com/pion/interceptor v0.1.44/go.mod h1:4atVlBkcgXuUP+ykQF0qOCGU2j7pQzX2ofvPRFsY5RY= -github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8= -github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so= -github.com/pion/mdns/v2 v2.1.0 h1:3IJ9+Xio6tWYjhN6WwuY142P/1jA0D5ERaIqawg/fOY= -github.com/pion/mdns/v2 v2.1.0/go.mod h1:pcez23GdynwcfRU1977qKU0mDxSeucttSHbCSfFOd9A= -github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA= -github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8= -github.com/pion/rtcp v1.2.16 h1:fk1B1dNW4hsI78XUCljZJlC4kZOPk67mNRuQ0fcEkSo= -github.com/pion/rtcp v1.2.16/go.mod h1:/as7VKfYbs5NIb4h6muQ35kQF/J0ZVNz2Z3xKoCBYOo= -github.com/pion/rtp v1.10.1 h1:xP1prZcCTUuhO2c83XtxyOHJteISg6o8iPsE2acaMtA= -github.com/pion/rtp v1.10.1/go.mod h1:rF5nS1GqbR7H/TCpKwylzeq6yDM+MM6k+On5EgeThEM= -github.com/pion/sctp v1.9.2 h1:HxsOzEV9pWoeggv7T5kewVkstFNcGvhMPx0GvUOUQXo= -github.com/pion/sctp v1.9.2/go.mod h1:OTOlsQ5EDQ6mQ0z4MUGXt2CgQmKyafBEXhUVqLRB6G8= -github.com/pion/sdp/v3 v3.0.18 h1:l0bAXazKHpepazVdp+tPYnrsy9dfh7ZbT8DxesH5ZnI= -github.com/pion/sdp/v3 v3.0.18/go.mod h1:ZREGo6A9ZygQ9XkqAj5xYCQtQpif0i6Pa81HOiAdqQ8= -github.com/pion/srtp/v3 v3.0.10 h1:tFirkpBb3XccP5VEXLi50GqXhv5SKPxqrdlhDCJlZrQ= -github.com/pion/srtp/v3 v3.0.10/go.mod h1:3mOTIB0cq9qlbn59V4ozvv9ClW/BSEbRp4cY0VtaR7M= -github.com/pion/stun v0.6.1 h1:8lp6YejULeHBF8NmV8e2787BogQhduZugh5PdhDyyN4= -github.com/pion/stun v0.6.1/go.mod h1:/hO7APkX4hZKu/D0f2lHzNyvdkTGtIy3NDmLR7kSz/8= -github.com/pion/stun/v3 v3.1.1 h1:CkQxveJ4xGQjulGSROXbXq94TAWu8gIX2dT+ePhUkqw= -github.com/pion/stun/v3 v3.1.1/go.mod h1:qC1DfmcCTQjl9PBaMa5wSn3x9IPmKxSdcCsxBcDBndM= -github.com/pion/transport/v2 v2.2.10 h1:ucLBLE8nuxiHfvkFKnkDQRYWYfp8ejf4YBOPfaQpw6Q= -github.com/pion/transport/v2 v2.2.10/go.mod h1:sq1kSLWs+cHW9E+2fJP95QudkzbK7wscs8yYgQToO5E= -github.com/pion/transport/v4 v4.0.1 h1:sdROELU6BZ63Ab7FrOLn13M6YdJLY20wldXW2Cu2k8o= -github.com/pion/transport/v4 v4.0.1/go.mod h1:nEuEA4AD5lPdcIegQDpVLgNoDGreqM/YqmEx3ovP4jM= -github.com/pion/turn/v4 v4.1.4 h1:EU11yMXKIsK43FhcUnjLlrhE4nboHZq+TXBIi3QpcxQ= -github.com/pion/turn/v4 v4.1.4/go.mod h1:ES1DXVFKnOhuDkqn9hn5VJlSWmZPaRJLyBXoOeO/BmQ= -github.com/pion/webrtc/v4 v4.2.9 h1:DZIh1HAhPIL3RvwEDFsmL5hfPSLEpxsQk9/Jir2vkJE= -github.com/pion/webrtc/v4 v4.2.9/go.mod h1:9EmLZve0H76eTzf8v2FmchZ6tcBXtDgpfTEu+drW6SY= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= @@ -560,21 +405,7 @@ github.com/polydawn/refmt v0.89.1-0.20231129105047-37766d95467a/go.mod h1:ocZfO/ github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= -github.com/prometheus/client_golang v1.21.1 h1:DOvXXTqVzvkIewV/CDPFdejpMCGeMcbGCQ8YOmu+Ibk= -github.com/prometheus/client_golang v1.21.1/go.mod h1:U9NM32ykUErtVBxdvD3zfi+EuFkkaBvMb09mIfe0Zgg= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= -github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= -github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= -github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io= -github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I= -github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= -github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= -github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= -github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= -github.com/quic-go/quic-go v0.50.1 h1:unsgjFIUqW8a2oopkY7YNONpV1gYND6Nt9hnt1PN94Q= -github.com/quic-go/quic-go v0.50.1/go.mod h1:Vim6OmUvlYdwBhXP9ZVrtGmCMWa3wEqhq3NgYrI8b4E= -github.com/quic-go/webtransport-go v0.8.1-0.20241018022711-4ac2c9250e66 h1:4WFk6u3sOT6pLa1kQ50ZVdm8BQFgJNA117cepZxtLIg= -github.com/quic-go/webtransport-go v0.8.1-0.20241018022711-4ac2c9250e66/go.mod h1:Vp72IJajgeOL6ddqrAhmp7IM9zbTcgkQxD/YdxrVwMw= github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= @@ -623,10 +454,6 @@ github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3A github.com/spf13/viper v1.8.1/go.mod h1:o0Pch8wJ9BVSWGQMbra6iw0oQ5oktSIBaujf1rJH9Ns= github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= -github.com/storacha/go-libstoracha v0.7.5 h1:zfRbku2RXxbH0uNWnpGQyJqafiJ+uCGs3tMmkHgZ/QE= -github.com/storacha/go-libstoracha v0.7.5/go.mod h1:htUh/VZ0qHRLPJKWZsgXv9mCOqlAFGTVS//ApvQVNf0= -github.com/storacha/go-ucanto v0.7.2 h1:sLg+swDM/6VEcrb9VOik3hP8ek3NvqqKWiZRmsva5X0= -github.com/storacha/go-ucanto v0.7.2/go.mod h1:DZlWyzuSkXk3phAuJpGDyhxYWpJogW1RFqp/VfldT64= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= @@ -651,21 +478,15 @@ github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYI github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI= github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw= github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ= -github.com/ucan-wg/go-ucan v0.0.0-20240916120445-37f52863156c h1:A1pMNIlHPnJ6KROqNc6SKg7QlSiQA6umiEoy89Os4cM= -github.com/ucan-wg/go-ucan v0.0.0-20240916120445-37f52863156c/go.mod h1:IiRc1OKWUk7FziOTWmOo7iwbcEMr7ch0lgs3UrF13pU= github.com/urfave/cli v1.22.10/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= -github.com/warpfork/go-testmark v0.12.1 h1:rMgCpJfwy1sJ50x0M0NgyphxYYPMOODIJHhsXyEHU0s= -github.com/warpfork/go-testmark v0.12.1/go.mod h1:kHwy7wfvGSPh1rQJYKayD4AbtNaeyZdcGi9tNJTaa5Y= github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0 h1:GDDkbFiaK8jsSDJfjId/PEGEShv6ugrt4kYsC5UIDaQ= github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0/go.mod h1:x6AKhvSSexNrVSrViXSHUEbICjmGXhtgABaHIySUSGw= github.com/whyrusleeping/cbor-gen v0.3.1 h1:82ioxmhEYut7LBVGhGq8xoRkXPLElVuh5mV67AFfdv0= github.com/whyrusleeping/cbor-gen v0.3.1/go.mod h1:pM99HXyEbSQHcosHc0iW7YFmwnscr+t9Te4ibko05so= -github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU= -github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA= github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= @@ -674,6 +495,10 @@ github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1 github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b h1:CzigHMRySiX3drau9C6Q5CAbNIApmLdat5jPMqChvDA= +gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b/go.mod h1:/y/V339mxv2sZmYYR64O07VuCpdNZqCTwO8ZcouTMI8= +gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 h1:qwDnMxjkyLmAFgcfgTnfJrmYKWhHnci3GjDqcZp1M3Q= +gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02/go.mod h1:JTnUj0mpYiAsuZLmKjTx/ex3AtMowcCgnE7YNyCEP0I= go.etcd.io/etcd/api/v3 v3.5.0/go.mod h1:cbVKeC6lCfl7j/8jBhAK6aIYO9XOjdptoxU/nLQcPvs= go.etcd.io/etcd/client/pkg/v3 v3.5.0/go.mod h1:IJHfcCEKxYu1Os13ZdwCwIUTUVGYTSAM3YSwc9/Ac1g= go.etcd.io/etcd/client/v2 v2.305.0/go.mod h1:h9puh54ZTgAKtEbut2oe9P4L/oqKCVB6xsXlzd7alYQ= @@ -704,27 +529,19 @@ go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4Len go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc= go.opentelemetry.io/proto/otlp v1.0.0 h1:T0TX0tmXU8a3CbNXzEKGeU5mIVOdf0oykP+u2lIVU/I= go.opentelemetry.io/proto/otlp v1.0.0/go.mod h1:Sy6pihPLfYHkr3NkUbEhGHFhINUSI/v80hjKIs5JXpM= -go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= -go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= -go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.uber.org/dig v1.19.0 h1:BACLhebsYdpQ7IROQ1AGPjrXcP5dF80U3gKoFzbaq/4= go.uber.org/dig v1.19.0/go.mod h1:Us0rSJiThwCv2GteUN0Q7OKvU7n5J4dxZ9JKUXozFdE= go.uber.org/fx v1.24.0 h1:wE8mruvpg2kiiL1Vqd0CC+tr0/24XIB10Iwp2lLWzkg= go.uber.org/fx v1.24.0/go.mod h1:AmDeGyS+ZARGKM4tlH4FY2Jr63VjbEDJHtqXTGP5hbo= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= -go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU= -go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= -go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU= go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= -go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA= -go.uber.org/zap v1.16.0/go.mod h1:MA8QOfq0BHJwdXa996Y4dYkAqRKB8/1K1QMMZVaNZjQ= go.uber.org/zap v1.17.0/go.mod h1:MXVU+bhUf/A7Xi2HNOnopQOrmycQ5Ih87HtOu4q5SSo= -go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= -go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= +go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= @@ -736,8 +553,8 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= -golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= +golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= @@ -748,8 +565,6 @@ golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u0 golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= -golang.org/x/exp v0.0.0-20250218142911-aa4b98e5adaa h1:t2QcU6V556bFjYgu4L6C+6VrCPyJZ+eyRsABUPs1mz4= -golang.org/x/exp v0.0.0-20250218142911-aa4b98e5adaa/go.mod h1:BHOTPb3L19zxehTsLoJXVaTktb06DFgmdW6Wb9s8jqk= golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= @@ -778,8 +593,6 @@ golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= -golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -819,8 +632,8 @@ golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96b golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= -golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= -golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= +golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= +golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -846,8 +659,6 @@ golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= -golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181026203630-95b1ffbd15a5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -900,14 +711,14 @@ golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= -golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= -golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= -golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= +golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= +golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -918,8 +729,8 @@ golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= -golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= -golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= @@ -941,8 +752,6 @@ golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgw golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191112195655-aa38f8e97acc/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= @@ -982,8 +791,6 @@ golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.7.0/go.mod h1:4pg6aUX35JBAogB10C9AtvVL+qowtN4pT3CGSQex14s= -golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= -golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -1128,6 +935,8 @@ honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9 honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg= lukechampine.com/blake3 v1.4.1/go.mod h1:QFosUxmjB8mnrWFSNwKmvxHpfY72bmD2tQ0kBMM3kwo= +pitr.ca/jsontokenizer v0.3.0 h1:Qr70hk4/wcpFEgu/6aJ+nvYQ6x/xS0WOkC627ceiI/M= +pitr.ca/jsontokenizer v0.3.0/go.mod h1:3DJdA2QNOU6cI0XkH6pRKZ4Oe8G5SDRUQ6PFAwaQ3YY= rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= diff --git a/internal/fx/app_test.go b/internal/fx/app_test.go index 765cd92..4986c10 100644 --- a/internal/fx/app_test.go +++ b/internal/fx/app_test.go @@ -4,8 +4,8 @@ import ( "runtime" "testing" + "github.com/fil-forge/ucantone/principal/signer" "github.com/google/uuid" - ed25519 "github.com/storacha/go-ucanto/principal/ed25519/signer" "github.com/storacha/sprue/internal/config" appfx "github.com/storacha/sprue/internal/fx" "github.com/storacha/sprue/internal/testutil" @@ -45,7 +45,7 @@ func TestWireApp(t *testing.T) { Port: 0, }, Identity: config.IdentityConfig{ - PrivateKey: testutil.Must(ed25519.Format(testutil.WebService))(t), + PrivateKey: signer.Format(testutil.WebService), ServiceDID: testutil.WebService.DID().String(), }, Indexer: config.IndexerConfig{ @@ -97,7 +97,7 @@ func TestWireApp(t *testing.T) { Port: 0, }, Identity: config.IdentityConfig{ - PrivateKey: testutil.Must(ed25519.Format(testutil.WebService))(t), + PrivateKey: signer.Format(testutil.WebService), ServiceDID: testutil.WebService.DID().String(), }, Indexer: config.IndexerConfig{ diff --git a/internal/fx/clients.go b/internal/fx/clients.go index b77cd97..49da371 100644 --- a/internal/fx/clients.go +++ b/internal/fx/clients.go @@ -3,10 +3,10 @@ package fx import ( "net/url" - "github.com/storacha/go-ucanto/did" "go.uber.org/fx" "go.uber.org/zap" + "github.com/fil-forge/ucantone/did" "github.com/storacha/sprue/internal/config" "github.com/storacha/sprue/pkg/identity" "github.com/storacha/sprue/pkg/indexerclient" diff --git a/internal/fx/service/handlers/provider.go b/internal/fx/service/handlers/provider.go index 32c2370..d31123d 100644 --- a/internal/fx/service/handlers/provider.go +++ b/internal/fx/service/handlers/provider.go @@ -3,84 +3,84 @@ package handlers import ( "go.uber.org/fx" - "github.com/storacha/go-ucanto/ucan" + "github.com/fil-forge/ucantone/ucan" "github.com/storacha/sprue/pkg/service/handlers" ) var Module = fx.Module("service-handlers", fx.Provide( fx.Annotate( - handlers.WithAccessAuthorizeMethod, - fx.ResultTags(`group:"ucan_options"`), + handlers.NewAccessClaimHandler, + fx.ResultTags(`group:"ucan_handlers"`), ), fx.Annotate( - handlers.WithAccessClaimMethod, - fx.ResultTags(`group:"ucan_options"`), + handlers.NewAccessConfirmHandler, + fx.ResultTags(`group:"ucan_handlers"`), ), fx.Annotate( - handlers.WithAccessDelegateMethod, - fx.ResultTags(`group:"ucan_options"`), + handlers.NewAccessDelegateHandler, + fx.ResultTags(`group:"ucan_handlers"`), ), fx.Annotate( - handlers.WithAdminProviderDeregisterMethod, - fx.ResultTags(`group:"ucan_options"`), + handlers.NewAccessRequestHandler, + fx.ResultTags(`group:"ucan_handlers"`), ), fx.Annotate( - handlers.WithAdminProviderListMethod, - fx.ResultTags(`group:"ucan_options"`), + handlers.NewAdminProviderDeregisterHandler, + fx.ResultTags(`group:"ucan_handlers"`), ), fx.Annotate( - handlers.WithAdminProviderRegisterMethod, - fx.ResultTags(`group:"ucan_options"`), + handlers.NewAdminProviderListHandler, + fx.ResultTags(`group:"ucan_handlers"`), ), fx.Annotate( - handlers.WithAdminProviderWeightSetMethod, - fx.ResultTags(`group:"ucan_options"`), + handlers.NewAdminProviderRegisterHandler, + fx.ResultTags(`group:"ucan_handlers"`), ), fx.Annotate( - handlers.WithFilecoinOfferMethod, - fx.ResultTags(`group:"ucan_options"`), + handlers.NewAdminProviderWeightSetHandler, + fx.ResultTags(`group:"ucan_handlers"`), ), fx.Annotate( - handlers.WithProviderAddMethod, - fx.ResultTags(`group:"ucan_options"`), + handlers.NewBlobAddHandler, + fx.ResultTags(`group:"ucan_handlers"`), ), + // fx.Annotate( + // handlers.NewBlobReplicateHandler, + // fx.ResultTags(`group:"ucan_handlers"`), + // ), fx.Annotate( - handlers.WithSpaceBlobAddMethod, - fx.ResultTags(`group:"ucan_options"`), + handlers.NewIndexAddHandler, + fx.ResultTags(`group:"ucan_handlers"`), ), fx.Annotate( - handlers.WithSpaceBlobReplicateMethod, - fx.ResultTags(`group:"ucan_options"`), + handlers.NewProviderAddHandler, + fx.ResultTags(`group:"ucan_handlers"`), ), fx.Annotate( - handlers.WithSpaceIndexAddMethod, - fx.ResultTags(`group:"ucan_options"`), + handlers.NewUCANConcludeHandler, + fx.ResultTags(`group:"ucan_handlers"`), ), fx.Annotate( - handlers.WithUCANConcludeMethod, - fx.ResultTags(`group:"ucan_options"`), + handlers.NewUploadAddHandler, + fx.ResultTags(`group:"ucan_handlers"`), ), fx.Annotate( - handlers.WithUploadAddMethod, - fx.ResultTags(`group:"ucan_options"`), + handlers.NewUploadListHandler, + fx.ResultTags(`group:"ucan_handlers"`), ), fx.Annotate( - handlers.WithUploadListMethod, - fx.ResultTags(`group:"ucan_options"`), - ), - fx.Annotate( - handlers.WithUploadShardListMethod, - fx.ResultTags(`group:"ucan_options"`), + handlers.NewUploadShardListHandler, + fx.ResultTags(`group:"ucan_handlers"`), ), fx.Annotate( handlers.NewHTTPPutConcludeHandler, fx.ResultTags(`group:"ucan_conclude_handlers"`), ), - fx.Annotate( - handlers.NewBlobReplicaTransferConcludeHandler, - fx.ResultTags(`group:"ucan_conclude_handlers"`), - ), + // fx.Annotate( + // handlers.NewBlobReplicaTransferConcludeHandler, + // fx.ResultTags(`group:"ucan_conclude_handlers"`), + // ), NewConcludeHandlers, ), ) @@ -90,10 +90,10 @@ type ConcludeHandlersParams struct { Handlers []handlers.ConclusionHandler `group:"ucan_conclude_handlers"` } -func NewConcludeHandlers(params ConcludeHandlersParams) map[ucan.Ability]handlers.ConclusionHandlerFunc { - handlers := make(map[ucan.Ability]handlers.ConclusionHandlerFunc, len(params.Handlers)) +func NewConcludeHandlers(params ConcludeHandlersParams) map[ucan.Command]handlers.ConclusionHandlerFunc { + handlers := make(map[ucan.Command]handlers.ConclusionHandlerFunc, len(params.Handlers)) for _, h := range params.Handlers { - handlers[h.Ability] = h.Handler + handlers[h.Command] = h.Handler } return handlers } diff --git a/internal/fx/service/provider.go b/internal/fx/service/provider.go index 5463761..8fdb163 100644 --- a/internal/fx/service/provider.go +++ b/internal/fx/service/provider.go @@ -4,10 +4,10 @@ import ( "go.uber.org/fx" "go.uber.org/zap" - "github.com/storacha/go-ucanto/server" + "github.com/fil-forge/ucantone/server" "github.com/storacha/sprue/pkg/identity" - "github.com/storacha/sprue/pkg/indexerclient" "github.com/storacha/sprue/pkg/service" + "github.com/storacha/sprue/pkg/service/handlers" "github.com/storacha/sprue/pkg/store/agent" "github.com/storacha/sprue/pkg/store/delegation" ) @@ -24,12 +24,12 @@ type ServiceParams struct { Identity *identity.Identity AgentStore agent.Store DelegationStore delegation.Store - IndexerClient *indexerclient.Client `optional:"true"` Logger *zap.Logger - Options []server.Option `group:"ucan_options"` + Handlers []handlers.Handler `group:"ucan_handlers"` + Options []server.HTTPOption `group:"ucan_options"` } // NewService creates the UCAN service with all handlers registered. -func NewService(p ServiceParams) (*service.Service, error) { - return service.New(p.Identity, p.AgentStore, p.DelegationStore, p.IndexerClient, p.Logger, p.Options...) +func NewService(p ServiceParams) *service.Service { + return service.New(p.Identity, p.AgentStore, p.DelegationStore, p.Handlers, p.Logger, p.Options...) } diff --git a/internal/testutil/alias.go b/internal/testutil/alias.go index d5f0be8..0507ba9 100644 --- a/internal/testutil/alias.go +++ b/internal/testutil/alias.go @@ -3,13 +3,14 @@ package testutil import ( "testing" + "github.com/fil-forge/libforge/testutil" "github.com/ipfs/go-cid" - "github.com/storacha/go-libstoracha/testutil" ) var ( Alice = testutil.Alice Bob = testutil.Bob + Carol = testutil.Carol Mallory = testutil.Mallory Service = testutil.Service WebService = testutil.WebService diff --git a/internal/testutil/aws.go b/internal/testutil/aws.go index 3fb6b2b..053adb8 100644 --- a/internal/testutil/aws.go +++ b/internal/testutil/aws.go @@ -10,7 +10,6 @@ import ( "github.com/aws/aws-sdk-go-v2/credentials" "github.com/aws/aws-sdk-go-v2/service/dynamodb" "github.com/aws/aws-sdk-go-v2/service/s3" - "github.com/storacha/go-libstoracha/testutil" "github.com/stretchr/testify/require" "github.com/testcontainers/testcontainers-go" tcdynamodb "github.com/testcontainers/testcontainers-go/modules/dynamodb" @@ -27,7 +26,7 @@ func CreateDynamo(t *testing.T) *url.URL { require.NoError(t, err) t.Logf("DynamoDB listening on: http://%s", endpoint) - return testutil.Must(url.Parse("http://" + endpoint))(t) + return Must(url.Parse("http://" + endpoint))(t) } func NewDynamoClient(t *testing.T, endpoint *url.URL) *dynamodb.Client { @@ -62,7 +61,7 @@ func CreateS3(t *testing.T) *url.URL { require.NoError(t, err) t.Logf("S3 listening on: http://%s", endpoint) - return testutil.Must(url.Parse("http://" + endpoint))(t) + return Must(url.Parse("http://" + endpoint))(t) } func NewS3Client(t *testing.T, endpoint *url.URL) *s3.Client { diff --git a/pkg/billing/service.go b/pkg/billing/service.go index 2dc738d..ea5daec 100644 --- a/pkg/billing/service.go +++ b/pkg/billing/service.go @@ -4,8 +4,8 @@ import ( "context" "fmt" - "github.com/storacha/go-ucanto/did" - "github.com/storacha/sprue/pkg/lib/errors" + "github.com/fil-forge/ucantone/did" + "github.com/fil-forge/ucantone/errors" "github.com/storacha/sprue/pkg/store/customer" ) @@ -30,9 +30,9 @@ func (s *Service) PaymentPlan(ctx context.Context, account did.DID) (did.DID, er r, err := s.customerStore.Get(ctx, account) if err != nil { if errors.Is(err, customer.ErrCustomerNotFound) { - return did.Undef, ErrMissingPaymentPlan + return did.DID{}, ErrMissingPaymentPlan } - return did.Undef, fmt.Errorf("getting customer: %w", err) + return did.DID{}, fmt.Errorf("getting customer: %w", err) } return r.Product, nil } diff --git a/pkg/billing/service_test.go b/pkg/billing/service_test.go index 1ca6e50..a53169b 100644 --- a/pkg/billing/service_test.go +++ b/pkg/billing/service_test.go @@ -4,8 +4,8 @@ import ( "context" "testing" - "github.com/storacha/go-ucanto/did" - "github.com/storacha/sprue/pkg/lib/didmailto" + "github.com/fil-forge/libforge/didmailto" + "github.com/fil-forge/ucantone/did" customermemory "github.com/storacha/sprue/pkg/store/customer/memory" "github.com/stretchr/testify/require" ) diff --git a/pkg/capabilities/admin/provider/datamodel/cbor_gen.go b/pkg/capabilities/admin/provider/datamodel/cbor_gen.go new file mode 100644 index 0000000..6719e9a --- /dev/null +++ b/pkg/capabilities/admin/provider/datamodel/cbor_gen.go @@ -0,0 +1,658 @@ +// Code generated by github.com/whyrusleeping/cbor-gen. DO NOT EDIT. + +package datamodel + +import ( + "fmt" + "io" + "math" + "sort" + + cid "github.com/ipfs/go-cid" + cbg "github.com/whyrusleeping/cbor-gen" + xerrors "golang.org/x/xerrors" +) + +var _ = xerrors.Errorf +var _ = cid.Undef +var _ = math.E +var _ = sort.Sort + +func (t *ListArgumentsModel) MarshalCBOR(w io.Writer) error { + if t == nil { + _, err := w.Write(cbg.CborNull) + return err + } + + cw := cbg.NewCborWriter(w) + + if _, err := cw.Write([]byte{160}); err != nil { + return err + } + return nil +} + +func (t *ListArgumentsModel) UnmarshalCBOR(r io.Reader) (err error) { + *t = ListArgumentsModel{} + + cr := cbg.NewCborReader(r) + + maj, extra, err := cr.ReadHeader() + if err != nil { + return err + } + defer func() { + if err == io.EOF { + err = io.ErrUnexpectedEOF + } + }() + + if maj != cbg.MajMap { + return fmt.Errorf("cbor input should be of type map") + } + + if extra > cbg.MaxLength { + return fmt.Errorf("ListArgumentsModel: map struct too large (%d)", extra) + } + + n := extra + + nameBuf := make([]byte, 0) + for i := uint64(0); i < n; i++ { + nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 8192) + if err != nil { + return err + } + + if !ok { + // Field doesn't exist on this type, so ignore it + if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { + return err + } + continue + } + + switch string(nameBuf[:nameLen]) { + + default: + // Field doesn't exist on this type, so ignore it + if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { + return err + } + } + } + + return nil +} +func (t *ProviderModel) MarshalCBOR(w io.Writer) error { + if t == nil { + _, err := w.Write(cbg.CborNull) + return err + } + + cw := cbg.NewCborWriter(w) + + if _, err := cw.Write([]byte{164}); err != nil { + return err + } + + // t.Weight (int64) (int64) + if len("weight") > 8192 { + return xerrors.Errorf("Value in field \"weight\" was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("weight"))); err != nil { + return err + } + if _, err := cw.WriteString(string("weight")); err != nil { + return err + } + + if t.Weight >= 0 { + if err := cw.WriteMajorTypeHeader(cbg.MajUnsignedInt, uint64(t.Weight)); err != nil { + return err + } + } else { + if err := cw.WriteMajorTypeHeader(cbg.MajNegativeInt, uint64(-t.Weight-1)); err != nil { + return err + } + } + + // t.Endpoint (string) (string) + if len("endpoint") > 8192 { + return xerrors.Errorf("Value in field \"endpoint\" was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("endpoint"))); err != nil { + return err + } + if _, err := cw.WriteString(string("endpoint")); err != nil { + return err + } + + if len(t.Endpoint) > 8192 { + return xerrors.Errorf("Value in field t.Endpoint was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Endpoint))); err != nil { + return err + } + if _, err := cw.WriteString(string(t.Endpoint)); err != nil { + return err + } + + // t.Provider (did.DID) (struct) + if len("provider") > 8192 { + return xerrors.Errorf("Value in field \"provider\" was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("provider"))); err != nil { + return err + } + if _, err := cw.WriteString(string("provider")); err != nil { + return err + } + + if err := t.Provider.MarshalCBOR(cw); err != nil { + return err + } + + // t.ReplicationWeight (int64) (int64) + if len("replicationWeight") > 8192 { + return xerrors.Errorf("Value in field \"replicationWeight\" was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("replicationWeight"))); err != nil { + return err + } + if _, err := cw.WriteString(string("replicationWeight")); err != nil { + return err + } + + if t.ReplicationWeight >= 0 { + if err := cw.WriteMajorTypeHeader(cbg.MajUnsignedInt, uint64(t.ReplicationWeight)); err != nil { + return err + } + } else { + if err := cw.WriteMajorTypeHeader(cbg.MajNegativeInt, uint64(-t.ReplicationWeight-1)); err != nil { + return err + } + } + + return nil +} + +func (t *ProviderModel) UnmarshalCBOR(r io.Reader) (err error) { + *t = ProviderModel{} + + cr := cbg.NewCborReader(r) + + maj, extra, err := cr.ReadHeader() + if err != nil { + return err + } + defer func() { + if err == io.EOF { + err = io.ErrUnexpectedEOF + } + }() + + if maj != cbg.MajMap { + return fmt.Errorf("cbor input should be of type map") + } + + if extra > cbg.MaxLength { + return fmt.Errorf("ProviderModel: map struct too large (%d)", extra) + } + + n := extra + + nameBuf := make([]byte, 17) + for i := uint64(0); i < n; i++ { + nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 8192) + if err != nil { + return err + } + + if !ok { + // Field doesn't exist on this type, so ignore it + if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { + return err + } + continue + } + + switch string(nameBuf[:nameLen]) { + // t.Weight (int64) (int64) + case "weight": + { + maj, extra, err := cr.ReadHeader() + if err != nil { + return err + } + var extraI int64 + switch maj { + case cbg.MajUnsignedInt: + extraI = int64(extra) + if extraI < 0 { + return fmt.Errorf("int64 positive overflow") + } + case cbg.MajNegativeInt: + extraI = int64(extra) + if extraI < 0 { + return fmt.Errorf("int64 negative overflow") + } + extraI = -1 - extraI + default: + return fmt.Errorf("wrong type for int64 field: %d", maj) + } + + t.Weight = int64(extraI) + } + // t.Endpoint (string) (string) + case "endpoint": + + { + sval, err := cbg.ReadStringWithMax(cr, 8192) + if err != nil { + return err + } + + t.Endpoint = string(sval) + } + // t.Provider (did.DID) (struct) + case "provider": + + { + + if err := t.Provider.UnmarshalCBOR(cr); err != nil { + return xerrors.Errorf("unmarshaling t.Provider: %w", err) + } + + } + // t.ReplicationWeight (int64) (int64) + case "replicationWeight": + { + maj, extra, err := cr.ReadHeader() + if err != nil { + return err + } + var extraI int64 + switch maj { + case cbg.MajUnsignedInt: + extraI = int64(extra) + if extraI < 0 { + return fmt.Errorf("int64 positive overflow") + } + case cbg.MajNegativeInt: + extraI = int64(extra) + if extraI < 0 { + return fmt.Errorf("int64 negative overflow") + } + extraI = -1 - extraI + default: + return fmt.Errorf("wrong type for int64 field: %d", maj) + } + + t.ReplicationWeight = int64(extraI) + } + + default: + // Field doesn't exist on this type, so ignore it + if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { + return err + } + } + } + + return nil +} +func (t *ListOKModel) MarshalCBOR(w io.Writer) error { + if t == nil { + _, err := w.Write(cbg.CborNull) + return err + } + + cw := cbg.NewCborWriter(w) + + if _, err := cw.Write([]byte{161}); err != nil { + return err + } + + // t.Providers ([]datamodel.ProviderModel) (slice) + if len("providers") > 8192 { + return xerrors.Errorf("Value in field \"providers\" was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("providers"))); err != nil { + return err + } + if _, err := cw.WriteString(string("providers")); err != nil { + return err + } + + if len(t.Providers) > 8192 { + return xerrors.Errorf("Slice value in field t.Providers was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Providers))); err != nil { + return err + } + for _, v := range t.Providers { + if err := v.MarshalCBOR(cw); err != nil { + return err + } + + } + return nil +} + +func (t *ListOKModel) UnmarshalCBOR(r io.Reader) (err error) { + *t = ListOKModel{} + + cr := cbg.NewCborReader(r) + + maj, extra, err := cr.ReadHeader() + if err != nil { + return err + } + defer func() { + if err == io.EOF { + err = io.ErrUnexpectedEOF + } + }() + + if maj != cbg.MajMap { + return fmt.Errorf("cbor input should be of type map") + } + + if extra > cbg.MaxLength { + return fmt.Errorf("ListOKModel: map struct too large (%d)", extra) + } + + n := extra + + nameBuf := make([]byte, 9) + for i := uint64(0); i < n; i++ { + nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 8192) + if err != nil { + return err + } + + if !ok { + // Field doesn't exist on this type, so ignore it + if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { + return err + } + continue + } + + switch string(nameBuf[:nameLen]) { + // t.Providers ([]datamodel.ProviderModel) (slice) + case "providers": + + maj, extra, err = cr.ReadHeader() + if err != nil { + return err + } + + if extra > 8192 { + return fmt.Errorf("t.Providers: array too large (%d)", extra) + } + + if maj != cbg.MajArray { + return fmt.Errorf("expected cbor array") + } + + if extra > 0 { + t.Providers = make([]ProviderModel, extra) + } + + for i := 0; i < int(extra); i++ { + { + var maj byte + var extra uint64 + var err error + _ = maj + _ = extra + _ = err + + { + + if err := t.Providers[i].UnmarshalCBOR(cr); err != nil { + return xerrors.Errorf("unmarshaling t.Providers[i]: %w", err) + } + + } + + } + } + + default: + // Field doesn't exist on this type, so ignore it + if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { + return err + } + } + } + + return nil +} +func (t *RegisterArgumentsModel) MarshalCBOR(w io.Writer) error { + if t == nil { + _, err := w.Write(cbg.CborNull) + return err + } + + cw := cbg.NewCborWriter(w) + + if _, err := cw.Write([]byte{162}); err != nil { + return err + } + + // t.Endpoint (string) (string) + if len("endpoint") > 8192 { + return xerrors.Errorf("Value in field \"endpoint\" was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("endpoint"))); err != nil { + return err + } + if _, err := cw.WriteString(string("endpoint")); err != nil { + return err + } + + if len(t.Endpoint) > 8192 { + return xerrors.Errorf("Value in field t.Endpoint was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Endpoint))); err != nil { + return err + } + if _, err := cw.WriteString(string(t.Endpoint)); err != nil { + return err + } + + // t.Provider (did.DID) (struct) + if len("provider") > 8192 { + return xerrors.Errorf("Value in field \"provider\" was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("provider"))); err != nil { + return err + } + if _, err := cw.WriteString(string("provider")); err != nil { + return err + } + + if err := t.Provider.MarshalCBOR(cw); err != nil { + return err + } + return nil +} + +func (t *RegisterArgumentsModel) UnmarshalCBOR(r io.Reader) (err error) { + *t = RegisterArgumentsModel{} + + cr := cbg.NewCborReader(r) + + maj, extra, err := cr.ReadHeader() + if err != nil { + return err + } + defer func() { + if err == io.EOF { + err = io.ErrUnexpectedEOF + } + }() + + if maj != cbg.MajMap { + return fmt.Errorf("cbor input should be of type map") + } + + if extra > cbg.MaxLength { + return fmt.Errorf("RegisterArgumentsModel: map struct too large (%d)", extra) + } + + n := extra + + nameBuf := make([]byte, 8) + for i := uint64(0); i < n; i++ { + nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 8192) + if err != nil { + return err + } + + if !ok { + // Field doesn't exist on this type, so ignore it + if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { + return err + } + continue + } + + switch string(nameBuf[:nameLen]) { + // t.Endpoint (string) (string) + case "endpoint": + + { + sval, err := cbg.ReadStringWithMax(cr, 8192) + if err != nil { + return err + } + + t.Endpoint = string(sval) + } + // t.Provider (did.DID) (struct) + case "provider": + + { + + if err := t.Provider.UnmarshalCBOR(cr); err != nil { + return xerrors.Errorf("unmarshaling t.Provider: %w", err) + } + + } + + default: + // Field doesn't exist on this type, so ignore it + if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { + return err + } + } + } + + return nil +} +func (t *DeregisterArgumentsModel) MarshalCBOR(w io.Writer) error { + if t == nil { + _, err := w.Write(cbg.CborNull) + return err + } + + cw := cbg.NewCborWriter(w) + + if _, err := cw.Write([]byte{161}); err != nil { + return err + } + + // t.Provider (did.DID) (struct) + if len("provider") > 8192 { + return xerrors.Errorf("Value in field \"provider\" was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("provider"))); err != nil { + return err + } + if _, err := cw.WriteString(string("provider")); err != nil { + return err + } + + if err := t.Provider.MarshalCBOR(cw); err != nil { + return err + } + return nil +} + +func (t *DeregisterArgumentsModel) UnmarshalCBOR(r io.Reader) (err error) { + *t = DeregisterArgumentsModel{} + + cr := cbg.NewCborReader(r) + + maj, extra, err := cr.ReadHeader() + if err != nil { + return err + } + defer func() { + if err == io.EOF { + err = io.ErrUnexpectedEOF + } + }() + + if maj != cbg.MajMap { + return fmt.Errorf("cbor input should be of type map") + } + + if extra > cbg.MaxLength { + return fmt.Errorf("DeregisterArgumentsModel: map struct too large (%d)", extra) + } + + n := extra + + nameBuf := make([]byte, 8) + for i := uint64(0); i < n; i++ { + nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 8192) + if err != nil { + return err + } + + if !ok { + // Field doesn't exist on this type, so ignore it + if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { + return err + } + continue + } + + switch string(nameBuf[:nameLen]) { + // t.Provider (did.DID) (struct) + case "provider": + + { + + if err := t.Provider.UnmarshalCBOR(cr); err != nil { + return xerrors.Errorf("unmarshaling t.Provider: %w", err) + } + + } + + default: + // Field doesn't exist on this type, so ignore it + if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { + return err + } + } + } + + return nil +} diff --git a/pkg/capabilities/admin/provider/datamodel/deregister.go b/pkg/capabilities/admin/provider/datamodel/deregister.go new file mode 100644 index 0000000..b5e542f --- /dev/null +++ b/pkg/capabilities/admin/provider/datamodel/deregister.go @@ -0,0 +1,7 @@ +package datamodel + +import "github.com/fil-forge/ucantone/did" + +type DeregisterArgumentsModel struct { + Provider did.DID `cborgen:"provider" dagjsongen:"provider"` +} diff --git a/pkg/capabilities/admin/provider/datamodel/gen/main.go b/pkg/capabilities/admin/provider/datamodel/gen/main.go new file mode 100644 index 0000000..ad846a0 --- /dev/null +++ b/pkg/capabilities/admin/provider/datamodel/gen/main.go @@ -0,0 +1,23 @@ +package main + +import ( + jsg "github.com/alanshaw/dag-json-gen" + pdm "github.com/storacha/sprue/pkg/capabilities/admin/provider/datamodel" + cbg "github.com/whyrusleeping/cbor-gen" +) + +func main() { + models := []any{ + pdm.ListArgumentsModel{}, + pdm.ProviderModel{}, + pdm.ListOKModel{}, + pdm.RegisterArgumentsModel{}, + pdm.DeregisterArgumentsModel{}, + } + if err := cbg.WriteMapEncodersToFile("../cbor_gen.go", "datamodel", models...); err != nil { + panic(err) + } + if err := jsg.WriteMapEncodersToFile("../json_gen.go", "datamodel", models...); err != nil { + panic(err) + } +} diff --git a/pkg/capabilities/admin/provider/datamodel/json_gen.go b/pkg/capabilities/admin/provider/datamodel/json_gen.go new file mode 100644 index 0000000..0634e1e --- /dev/null +++ b/pkg/capabilities/admin/provider/datamodel/json_gen.go @@ -0,0 +1,648 @@ +// Code generated by github.com/alanshaw/dag-json-gen. DO NOT EDIT. + +package datamodel + +import ( + "errors" + "fmt" + "io" + "math" + "sort" + + jsg "github.com/alanshaw/dag-json-gen" + cid "github.com/ipfs/go-cid" +) + +var _ = cid.Undef +var _ = math.E +var _ = sort.Sort +var _ = errors.Is + +func (t *ListArgumentsModel) MarshalDagJSON(w io.Writer) error { + jw := jsg.NewDagJsonWriter(w) + if t == nil { + err := jw.WriteNull() + return err + } + if err := jw.WriteObjectOpen(); err != nil { + return err + } + if err := jw.WriteObjectClose(); err != nil { + return err + } + return nil +} +func (t *ListArgumentsModel) UnmarshalDagJSON(r io.Reader) (err error) { + *t = ListArgumentsModel{} + + jr := jsg.NewDagJsonReader(r) + defer func() { + if err == io.EOF { + err = io.ErrUnexpectedEOF + } + }() + if err := jr.ReadObjectOpen(); err != nil { + return fmt.Errorf("ListArgumentsModel: %w", err) + } + close, err := jr.PeekObjectClose() + if err != nil { + return fmt.Errorf("ListArgumentsModel: %w", err) + } + if close { + if err := jr.ReadObjectClose(); err != nil { + return fmt.Errorf("ListArgumentsModel: %w", err) + } + } else { + for i := uint64(0); i < 8192; i++ { + name, err := jr.ReadString(8192) + if err != nil { + if errors.Is(err, jsg.ErrLimitExceeded) { + return fmt.Errorf("ListArgumentsModel: string too large") + } + return fmt.Errorf("ListArgumentsModel: %w", err) + } + if err := jr.ReadObjectColon(); err != nil { + return fmt.Errorf("ListArgumentsModel: %w", err) + } + switch name { + default: + // Field doesn't exist on this type, so ignore it + if err := jr.DiscardType(); err != nil { + return fmt.Errorf("ListArgumentsModel: ignoring field %s: %w", name, err) + } + } + + close, err := jr.ReadObjectCloseOrComma() + if err != nil { + return fmt.Errorf("ListArgumentsModel: %w", err) + } + if close { + break + } + if i == 8192-1 { + return fmt.Errorf("ListArgumentsModel: map too large") + } + } + } + + return nil +} +func (t *ProviderModel) MarshalDagJSON(w io.Writer) error { + jw := jsg.NewDagJsonWriter(w) + if t == nil { + err := jw.WriteNull() + return err + } + if err := jw.WriteObjectOpen(); err != nil { + return err + } + written := 0 + + // t.Endpoint (string) (string) + if len("endpoint") > 8192 { + return fmt.Errorf("String in field \"endpoint\" was too long") + } + if err := jw.WriteString(string("endpoint")); err != nil { + return fmt.Errorf("\"endpoint\": %w", err) + } + if err := jw.WriteObjectColon(); err != nil { + return err + } + if len(t.Endpoint) > 8192 { + return fmt.Errorf("String in field t.Endpoint was too long") + } + if err := jw.WriteString(string(t.Endpoint)); err != nil { + return fmt.Errorf("t.Endpoint: %w", err) + } + written++ + if written > 0 { + if err := jw.WriteComma(); err != nil { + return err + } + } + + // t.Provider (did.DID) (struct) + if len("provider") > 8192 { + return fmt.Errorf("String in field \"provider\" was too long") + } + if err := jw.WriteString(string("provider")); err != nil { + return fmt.Errorf("\"provider\": %w", err) + } + if err := jw.WriteObjectColon(); err != nil { + return err + } + if err := t.Provider.MarshalDagJSON(jw); err != nil { + return fmt.Errorf("t.Provider: %w", err) + } + written++ + if written > 0 { + if err := jw.WriteComma(); err != nil { + return err + } + } + + // t.ReplicationWeight (int64) (int64) + if len("replicationWeight") > 8192 { + return fmt.Errorf("String in field \"replicationWeight\" was too long") + } + if err := jw.WriteString(string("replicationWeight")); err != nil { + return fmt.Errorf("\"replicationWeight\": %w", err) + } + if err := jw.WriteObjectColon(); err != nil { + return err + } + + if err := jw.WriteInt64(int64(t.ReplicationWeight)); err != nil { + return fmt.Errorf("t.ReplicationWeight: %w", err) + } + + written++ + if written > 0 { + if err := jw.WriteComma(); err != nil { + return err + } + } + + // t.Weight (int64) (int64) + if len("weight") > 8192 { + return fmt.Errorf("String in field \"weight\" was too long") + } + if err := jw.WriteString(string("weight")); err != nil { + return fmt.Errorf("\"weight\": %w", err) + } + if err := jw.WriteObjectColon(); err != nil { + return err + } + + if err := jw.WriteInt64(int64(t.Weight)); err != nil { + return fmt.Errorf("t.Weight: %w", err) + } + + written++ + if err := jw.WriteObjectClose(); err != nil { + return err + } + return nil +} +func (t *ProviderModel) UnmarshalDagJSON(r io.Reader) (err error) { + *t = ProviderModel{} + + jr := jsg.NewDagJsonReader(r) + defer func() { + if err == io.EOF { + err = io.ErrUnexpectedEOF + } + }() + if err := jr.ReadObjectOpen(); err != nil { + return fmt.Errorf("ProviderModel: %w", err) + } + close, err := jr.PeekObjectClose() + if err != nil { + return fmt.Errorf("ProviderModel: %w", err) + } + if close { + if err := jr.ReadObjectClose(); err != nil { + return fmt.Errorf("ProviderModel: %w", err) + } + } else { + for i := uint64(0); i < 8192; i++ { + name, err := jr.ReadString(8192) + if err != nil { + if errors.Is(err, jsg.ErrLimitExceeded) { + return fmt.Errorf("ProviderModel: string too large") + } + return fmt.Errorf("ProviderModel: %w", err) + } + if err := jr.ReadObjectColon(); err != nil { + return fmt.Errorf("ProviderModel: %w", err) + } + switch name { + + // t.Endpoint (string) (string) + case "endpoint": + { + sval, err := jr.ReadString(8192) + if err != nil { + if errors.Is(err, jsg.ErrLimitExceeded) { + return fmt.Errorf("t.Endpoint: string too long") + } + return fmt.Errorf("t.Endpoint: %w", err) + } + t.Endpoint = string(sval) + } + + // t.Provider (did.DID) (struct) + case "provider": + + if err := t.Provider.UnmarshalDagJSON(jr); err != nil { + return fmt.Errorf("unmarshaling t.Provider: %w", err) + } + + // t.ReplicationWeight (int64) (int64) + case "replicationWeight": + { + + nval, err := jr.ReadNumberAsInt64() + if err != nil { + return fmt.Errorf("t.ReplicationWeight: %w", err) + } + t.ReplicationWeight = int64(nval) + + } + + // t.Weight (int64) (int64) + case "weight": + { + + nval, err := jr.ReadNumberAsInt64() + if err != nil { + return fmt.Errorf("t.Weight: %w", err) + } + t.Weight = int64(nval) + + } + default: + // Field doesn't exist on this type, so ignore it + if err := jr.DiscardType(); err != nil { + return fmt.Errorf("ProviderModel: ignoring field %s: %w", name, err) + } + } + + close, err := jr.ReadObjectCloseOrComma() + if err != nil { + return fmt.Errorf("ProviderModel: %w", err) + } + if close { + break + } + if i == 8192-1 { + return fmt.Errorf("ProviderModel: map too large") + } + } + } + + return nil +} +func (t *ListOKModel) MarshalDagJSON(w io.Writer) error { + jw := jsg.NewDagJsonWriter(w) + if t == nil { + err := jw.WriteNull() + return err + } + if err := jw.WriteObjectOpen(); err != nil { + return err + } + + // t.Providers ([]datamodel.ProviderModel) (slice) + if len("providers") > 8192 { + return fmt.Errorf("String in field \"providers\" was too long") + } + if err := jw.WriteString(string("providers")); err != nil { + return fmt.Errorf("\"providers\": %w", err) + } + if err := jw.WriteObjectColon(); err != nil { + return err + } + if len(t.Providers) > 8192 { + return fmt.Errorf("Slice value in field t.Providers was too long") + } + + if err := jw.WriteArrayOpen(); err != nil { + return fmt.Errorf("t.Providers: %w", err) + } + for i, v := range t.Providers { + if i > 0 { + if err := jw.WriteComma(); err != nil { + return fmt.Errorf("t.Providers: %w", err) + } + } + if err := v.MarshalDagJSON(jw); err != nil { + return fmt.Errorf("v: %w", err) + } + } + if err := jw.WriteArrayClose(); err != nil { + return fmt.Errorf("t.Providers: %w", err) + } + + if err := jw.WriteObjectClose(); err != nil { + return err + } + return nil +} +func (t *ListOKModel) UnmarshalDagJSON(r io.Reader) (err error) { + *t = ListOKModel{} + + jr := jsg.NewDagJsonReader(r) + defer func() { + if err == io.EOF { + err = io.ErrUnexpectedEOF + } + }() + if err := jr.ReadObjectOpen(); err != nil { + return fmt.Errorf("ListOKModel: %w", err) + } + close, err := jr.PeekObjectClose() + if err != nil { + return fmt.Errorf("ListOKModel: %w", err) + } + if close { + if err := jr.ReadObjectClose(); err != nil { + return fmt.Errorf("ListOKModel: %w", err) + } + } else { + for i := uint64(0); i < 8192; i++ { + name, err := jr.ReadString(8192) + if err != nil { + if errors.Is(err, jsg.ErrLimitExceeded) { + return fmt.Errorf("ListOKModel: string too large") + } + return fmt.Errorf("ListOKModel: %w", err) + } + if err := jr.ReadObjectColon(); err != nil { + return fmt.Errorf("ListOKModel: %w", err) + } + switch name { + + // t.Providers ([]datamodel.ProviderModel) (slice) + case "providers": + { + + if err := jr.ReadArrayOpen(); err != nil { + return fmt.Errorf("t.Providers: %w", err) + } + + close, err := jr.PeekArrayClose() + if err != nil { + return fmt.Errorf("t.Providers: %w", err) + } + if close { + if err := jr.ReadArrayClose(); err != nil { + return fmt.Errorf("t.Providers: %w", err) + } + + } else { + for i := 0; i < 8192; i++ { + item := make([]ProviderModel, 1) + + if err := item[0].UnmarshalDagJSON(jr); err != nil { + return fmt.Errorf("unmarshaling item[0]: %w", err) + } + + t.Providers = append(t.Providers, item[0]) + + close, err := jr.ReadArrayCloseOrComma() + if err != nil { + return fmt.Errorf("t.Providers: %w", err) + } + if close { + break + } + if i == 8192-1 { + return fmt.Errorf("t.Providers: slice too large") + } + } + } + + } + default: + // Field doesn't exist on this type, so ignore it + if err := jr.DiscardType(); err != nil { + return fmt.Errorf("ListOKModel: ignoring field %s: %w", name, err) + } + } + + close, err := jr.ReadObjectCloseOrComma() + if err != nil { + return fmt.Errorf("ListOKModel: %w", err) + } + if close { + break + } + if i == 8192-1 { + return fmt.Errorf("ListOKModel: map too large") + } + } + } + + return nil +} +func (t *RegisterArgumentsModel) MarshalDagJSON(w io.Writer) error { + jw := jsg.NewDagJsonWriter(w) + if t == nil { + err := jw.WriteNull() + return err + } + if err := jw.WriteObjectOpen(); err != nil { + return err + } + written := 0 + + // t.Endpoint (string) (string) + if len("endpoint") > 8192 { + return fmt.Errorf("String in field \"endpoint\" was too long") + } + if err := jw.WriteString(string("endpoint")); err != nil { + return fmt.Errorf("\"endpoint\": %w", err) + } + if err := jw.WriteObjectColon(); err != nil { + return err + } + if len(t.Endpoint) > 8192 { + return fmt.Errorf("String in field t.Endpoint was too long") + } + if err := jw.WriteString(string(t.Endpoint)); err != nil { + return fmt.Errorf("t.Endpoint: %w", err) + } + written++ + if written > 0 { + if err := jw.WriteComma(); err != nil { + return err + } + } + + // t.Provider (did.DID) (struct) + if len("provider") > 8192 { + return fmt.Errorf("String in field \"provider\" was too long") + } + if err := jw.WriteString(string("provider")); err != nil { + return fmt.Errorf("\"provider\": %w", err) + } + if err := jw.WriteObjectColon(); err != nil { + return err + } + if err := t.Provider.MarshalDagJSON(jw); err != nil { + return fmt.Errorf("t.Provider: %w", err) + } + written++ + if err := jw.WriteObjectClose(); err != nil { + return err + } + return nil +} +func (t *RegisterArgumentsModel) UnmarshalDagJSON(r io.Reader) (err error) { + *t = RegisterArgumentsModel{} + + jr := jsg.NewDagJsonReader(r) + defer func() { + if err == io.EOF { + err = io.ErrUnexpectedEOF + } + }() + if err := jr.ReadObjectOpen(); err != nil { + return fmt.Errorf("RegisterArgumentsModel: %w", err) + } + close, err := jr.PeekObjectClose() + if err != nil { + return fmt.Errorf("RegisterArgumentsModel: %w", err) + } + if close { + if err := jr.ReadObjectClose(); err != nil { + return fmt.Errorf("RegisterArgumentsModel: %w", err) + } + } else { + for i := uint64(0); i < 8192; i++ { + name, err := jr.ReadString(8192) + if err != nil { + if errors.Is(err, jsg.ErrLimitExceeded) { + return fmt.Errorf("RegisterArgumentsModel: string too large") + } + return fmt.Errorf("RegisterArgumentsModel: %w", err) + } + if err := jr.ReadObjectColon(); err != nil { + return fmt.Errorf("RegisterArgumentsModel: %w", err) + } + switch name { + + // t.Endpoint (string) (string) + case "endpoint": + { + sval, err := jr.ReadString(8192) + if err != nil { + if errors.Is(err, jsg.ErrLimitExceeded) { + return fmt.Errorf("t.Endpoint: string too long") + } + return fmt.Errorf("t.Endpoint: %w", err) + } + t.Endpoint = string(sval) + } + + // t.Provider (did.DID) (struct) + case "provider": + + if err := t.Provider.UnmarshalDagJSON(jr); err != nil { + return fmt.Errorf("unmarshaling t.Provider: %w", err) + } + + default: + // Field doesn't exist on this type, so ignore it + if err := jr.DiscardType(); err != nil { + return fmt.Errorf("RegisterArgumentsModel: ignoring field %s: %w", name, err) + } + } + + close, err := jr.ReadObjectCloseOrComma() + if err != nil { + return fmt.Errorf("RegisterArgumentsModel: %w", err) + } + if close { + break + } + if i == 8192-1 { + return fmt.Errorf("RegisterArgumentsModel: map too large") + } + } + } + + return nil +} +func (t *DeregisterArgumentsModel) MarshalDagJSON(w io.Writer) error { + jw := jsg.NewDagJsonWriter(w) + if t == nil { + err := jw.WriteNull() + return err + } + if err := jw.WriteObjectOpen(); err != nil { + return err + } + + // t.Provider (did.DID) (struct) + if len("provider") > 8192 { + return fmt.Errorf("String in field \"provider\" was too long") + } + if err := jw.WriteString(string("provider")); err != nil { + return fmt.Errorf("\"provider\": %w", err) + } + if err := jw.WriteObjectColon(); err != nil { + return err + } + if err := t.Provider.MarshalDagJSON(jw); err != nil { + return fmt.Errorf("t.Provider: %w", err) + } + if err := jw.WriteObjectClose(); err != nil { + return err + } + return nil +} +func (t *DeregisterArgumentsModel) UnmarshalDagJSON(r io.Reader) (err error) { + *t = DeregisterArgumentsModel{} + + jr := jsg.NewDagJsonReader(r) + defer func() { + if err == io.EOF { + err = io.ErrUnexpectedEOF + } + }() + if err := jr.ReadObjectOpen(); err != nil { + return fmt.Errorf("DeregisterArgumentsModel: %w", err) + } + close, err := jr.PeekObjectClose() + if err != nil { + return fmt.Errorf("DeregisterArgumentsModel: %w", err) + } + if close { + if err := jr.ReadObjectClose(); err != nil { + return fmt.Errorf("DeregisterArgumentsModel: %w", err) + } + } else { + for i := uint64(0); i < 8192; i++ { + name, err := jr.ReadString(8192) + if err != nil { + if errors.Is(err, jsg.ErrLimitExceeded) { + return fmt.Errorf("DeregisterArgumentsModel: string too large") + } + return fmt.Errorf("DeregisterArgumentsModel: %w", err) + } + if err := jr.ReadObjectColon(); err != nil { + return fmt.Errorf("DeregisterArgumentsModel: %w", err) + } + switch name { + + // t.Provider (did.DID) (struct) + case "provider": + + if err := t.Provider.UnmarshalDagJSON(jr); err != nil { + return fmt.Errorf("unmarshaling t.Provider: %w", err) + } + + default: + // Field doesn't exist on this type, so ignore it + if err := jr.DiscardType(); err != nil { + return fmt.Errorf("DeregisterArgumentsModel: ignoring field %s: %w", name, err) + } + } + + close, err := jr.ReadObjectCloseOrComma() + if err != nil { + return fmt.Errorf("DeregisterArgumentsModel: %w", err) + } + if close { + break + } + if i == 8192-1 { + return fmt.Errorf("DeregisterArgumentsModel: map too large") + } + } + } + + return nil +} diff --git a/pkg/capabilities/admin/provider/datamodel/list.go b/pkg/capabilities/admin/provider/datamodel/list.go new file mode 100644 index 0000000..1227185 --- /dev/null +++ b/pkg/capabilities/admin/provider/datamodel/list.go @@ -0,0 +1,16 @@ +package datamodel + +import "github.com/fil-forge/ucantone/did" + +type ListArgumentsModel struct{} + +type ProviderModel struct { + Provider did.DID `cborgen:"provider" dagjsongen:"provider"` + Endpoint string `cborgen:"endpoint" dagjsongen:"endpoint"` + Weight int64 `cborgen:"weight" dagjsongen:"weight"` + ReplicationWeight int64 `cborgen:"replicationWeight" dagjsongen:"replicationWeight"` +} + +type ListOKModel struct { + Providers []ProviderModel `cborgen:"providers" dagjsongen:"providers"` +} diff --git a/pkg/capabilities/admin/provider/datamodel/register.go b/pkg/capabilities/admin/provider/datamodel/register.go new file mode 100644 index 0000000..d867cf4 --- /dev/null +++ b/pkg/capabilities/admin/provider/datamodel/register.go @@ -0,0 +1,8 @@ +package datamodel + +import "github.com/fil-forge/ucantone/did" + +type RegisterArgumentsModel struct { + Provider did.DID `cborgen:"provider" dagjsongen:"provider"` + Endpoint string `cborgen:"endpoint" dagjsongen:"endpoint"` +} diff --git a/pkg/capabilities/admin/provider/deregister.go b/pkg/capabilities/admin/provider/deregister.go index ae8e3a0..4670e9f 100644 --- a/pkg/capabilities/admin/provider/deregister.go +++ b/pkg/capabilities/admin/provider/deregister.go @@ -1,31 +1,16 @@ package provider import ( - "github.com/ipld/go-ipld-prime/datamodel" - "github.com/storacha/go-ucanto/core/ipld" - "github.com/storacha/go-ucanto/core/result/ok" - "github.com/storacha/go-ucanto/core/schema" - "github.com/storacha/go-ucanto/did" - "github.com/storacha/go-ucanto/validator" - - "github.com/storacha/go-libstoracha/capabilities/types" + cdm "github.com/fil-forge/libforge/capabilities/datamodel" + "github.com/fil-forge/ucantone/validator/bindcap" + pdm "github.com/storacha/sprue/pkg/capabilities/admin/provider/datamodel" ) -const DeregisterAbility = "admin/provider/deregister" - -type DeregisterCaveats struct { - Provider did.DID -} - -func (dc DeregisterCaveats) ToIPLD() (datamodel.Node, error) { - return ipld.WrapWithRecovery(&dc, DeregisterCaveatsType(), types.Converters...) -} +const DeregisterCommand = "/admin/provider/deregister" -type DeregisterOk = ok.Unit - -var Deregister = validator.NewCapability( - DeregisterAbility, - schema.DIDString(), - schema.Struct[DeregisterCaveats](DeregisterCaveatsType(), nil, types.Converters...), - validator.DefaultDerives[DeregisterCaveats], +type ( + DeregisterArguments = pdm.DeregisterArgumentsModel + DeregisterOK = cdm.UnitModel ) + +var Deregister, _ = bindcap.New[*DeregisterArguments](DeregisterCommand) diff --git a/pkg/capabilities/admin/provider/list.go b/pkg/capabilities/admin/provider/list.go index 09ff83b..467dd2b 100644 --- a/pkg/capabilities/admin/provider/list.go +++ b/pkg/capabilities/admin/provider/list.go @@ -1,41 +1,16 @@ package provider import ( - "github.com/ipld/go-ipld-prime/datamodel" - "github.com/storacha/go-ucanto/core/ipld" - "github.com/storacha/go-ucanto/core/schema" - "github.com/storacha/go-ucanto/did" - "github.com/storacha/go-ucanto/validator" - - "github.com/storacha/go-libstoracha/capabilities/types" + "github.com/fil-forge/ucantone/validator/bindcap" + pdm "github.com/storacha/sprue/pkg/capabilities/admin/provider/datamodel" ) -const ListAbility = "admin/provider/list" - -type ListCaveats struct{} - -func (lc ListCaveats) ToIPLD() (datamodel.Node, error) { - return ipld.WrapWithRecovery(&lc, ListCaveatsType(), types.Converters...) -} - -type Provider struct { - ID did.DID - Endpoint string - Weight int - ReplicationWeight int -} +const ListCommand = "/admin/provider/list" -type ListOk struct { - Providers []Provider -} - -func (lo ListOk) ToIPLD() (datamodel.Node, error) { - return ipld.WrapWithRecovery(&lo, ListOkType(), types.Converters...) -} - -var List = validator.NewCapability( - ListAbility, - schema.DIDString(), - schema.Struct[ListCaveats](ListCaveatsType(), nil, types.Converters...), - validator.DefaultDerives[ListCaveats], +type ( + ListArguments = pdm.ListArgumentsModel + ListOK = pdm.ListOKModel + Provider = pdm.ProviderModel ) + +var List, _ = bindcap.New[*ListArguments](ListCommand) diff --git a/pkg/capabilities/admin/provider/provider.ipldsch b/pkg/capabilities/admin/provider/provider.ipldsch deleted file mode 100644 index 7dfdafb..0000000 --- a/pkg/capabilities/admin/provider/provider.ipldsch +++ /dev/null @@ -1,33 +0,0 @@ -type RegisterCaveats struct { - endpoint String - proof Link -} - -type RegisterOk struct {} - -type DeregisterCaveats struct { - provider DID -} - -type DeregisterOk struct {} - -type ListCaveats struct {} - -type Provider struct { - ID DID (rename "id") - endpoint String - weight Int - replicationWeight Int -} - -type ListOk struct { - providers [Provider] -} - -type WeightSetCaveats struct { - provider DID - weight Int - replicationWeight Int -} - -type WeightSetOk struct {} diff --git a/pkg/capabilities/admin/provider/register.go b/pkg/capabilities/admin/provider/register.go index a08e34d..70a066a 100644 --- a/pkg/capabilities/admin/provider/register.go +++ b/pkg/capabilities/admin/provider/register.go @@ -1,31 +1,16 @@ package provider import ( - "github.com/ipld/go-ipld-prime/datamodel" - "github.com/storacha/go-ucanto/core/ipld" - "github.com/storacha/go-ucanto/core/result/ok" - "github.com/storacha/go-ucanto/core/schema" - "github.com/storacha/go-ucanto/validator" - - "github.com/storacha/go-libstoracha/capabilities/types" + cdm "github.com/fil-forge/libforge/capabilities/datamodel" + "github.com/fil-forge/ucantone/validator/bindcap" + pdm "github.com/storacha/sprue/pkg/capabilities/admin/provider/datamodel" ) -const RegisterAbility = "admin/provider/register" - -type RegisterCaveats struct { - Endpoint string - Proof ipld.Link -} - -func (rc RegisterCaveats) ToIPLD() (datamodel.Node, error) { - return ipld.WrapWithRecovery(&rc, RegisterCaveatsType(), types.Converters...) -} +const RegisterCommand = "/admin/provider/register" -type RegisterOk = ok.Unit - -var Register = validator.NewCapability( - RegisterAbility, - schema.DIDString(), - schema.Struct[RegisterCaveats](RegisterCaveatsType(), nil, types.Converters...), - validator.DefaultDerives[RegisterCaveats], +type ( + RegisterArguments = pdm.RegisterArgumentsModel + RegisterOK = cdm.UnitModel ) + +var Register, _ = bindcap.New[*RegisterArguments](RegisterCommand) diff --git a/pkg/capabilities/admin/provider/schema.go b/pkg/capabilities/admin/provider/schema.go deleted file mode 100644 index d45de82..0000000 --- a/pkg/capabilities/admin/provider/schema.go +++ /dev/null @@ -1,54 +0,0 @@ -package provider - -import ( - _ "embed" - "fmt" - - "github.com/ipld/go-ipld-prime/schema" - "github.com/storacha/go-libstoracha/capabilities/types" -) - -//go:embed provider.ipldsch -var providerSchema []byte - -var providerTS = mustLoadTS() - -func mustLoadTS() *schema.TypeSystem { - ts, err := types.LoadSchemaBytes(providerSchema) - if err != nil { - panic(fmt.Errorf("loading provider schema: %w", err)) - } - return ts -} - -func RegisterCaveatsType() schema.Type { - return providerTS.TypeByName("RegisterCaveats") -} - -func RegisterOkType() schema.Type { - return providerTS.TypeByName("RegisterOk") -} - -func DeregisterCaveatsType() schema.Type { - return providerTS.TypeByName("DeregisterCaveats") -} - -func DeregisterOkType() schema.Type { - return providerTS.TypeByName("DeregisterOk") -} - -func ListCaveatsType() schema.Type { - return providerTS.TypeByName("ListCaveats") -} - -func ListOkType() schema.Type { - return providerTS.TypeByName("ListOk") -} - -func WeightSetCaveatsType() schema.Type { - return providerTS.TypeByName("WeightSetCaveats") -} - -func WeightSetOkType() schema.Type { - return providerTS.TypeByName("WeightSetOk") -} diff --git a/pkg/capabilities/admin/provider/weight/datamodel/cbor_gen.go b/pkg/capabilities/admin/provider/weight/datamodel/cbor_gen.go new file mode 100644 index 0000000..546e152 --- /dev/null +++ b/pkg/capabilities/admin/provider/weight/datamodel/cbor_gen.go @@ -0,0 +1,209 @@ +// Code generated by github.com/whyrusleeping/cbor-gen. DO NOT EDIT. + +package datamodel + +import ( + "fmt" + "io" + "math" + "sort" + + cid "github.com/ipfs/go-cid" + cbg "github.com/whyrusleeping/cbor-gen" + xerrors "golang.org/x/xerrors" +) + +var _ = xerrors.Errorf +var _ = cid.Undef +var _ = math.E +var _ = sort.Sort + +func (t *SetArgumentsModel) MarshalCBOR(w io.Writer) error { + if t == nil { + _, err := w.Write(cbg.CborNull) + return err + } + + cw := cbg.NewCborWriter(w) + + if _, err := cw.Write([]byte{163}); err != nil { + return err + } + + // t.Weight (int64) (int64) + if len("weight") > 8192 { + return xerrors.Errorf("Value in field \"weight\" was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("weight"))); err != nil { + return err + } + if _, err := cw.WriteString(string("weight")); err != nil { + return err + } + + if t.Weight >= 0 { + if err := cw.WriteMajorTypeHeader(cbg.MajUnsignedInt, uint64(t.Weight)); err != nil { + return err + } + } else { + if err := cw.WriteMajorTypeHeader(cbg.MajNegativeInt, uint64(-t.Weight-1)); err != nil { + return err + } + } + + // t.Provider (did.DID) (struct) + if len("provider") > 8192 { + return xerrors.Errorf("Value in field \"provider\" was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("provider"))); err != nil { + return err + } + if _, err := cw.WriteString(string("provider")); err != nil { + return err + } + + if err := t.Provider.MarshalCBOR(cw); err != nil { + return err + } + + // t.ReplicationWeight (int64) (int64) + if len("replicationWeight") > 8192 { + return xerrors.Errorf("Value in field \"replicationWeight\" was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("replicationWeight"))); err != nil { + return err + } + if _, err := cw.WriteString(string("replicationWeight")); err != nil { + return err + } + + if t.ReplicationWeight >= 0 { + if err := cw.WriteMajorTypeHeader(cbg.MajUnsignedInt, uint64(t.ReplicationWeight)); err != nil { + return err + } + } else { + if err := cw.WriteMajorTypeHeader(cbg.MajNegativeInt, uint64(-t.ReplicationWeight-1)); err != nil { + return err + } + } + + return nil +} + +func (t *SetArgumentsModel) UnmarshalCBOR(r io.Reader) (err error) { + *t = SetArgumentsModel{} + + cr := cbg.NewCborReader(r) + + maj, extra, err := cr.ReadHeader() + if err != nil { + return err + } + defer func() { + if err == io.EOF { + err = io.ErrUnexpectedEOF + } + }() + + if maj != cbg.MajMap { + return fmt.Errorf("cbor input should be of type map") + } + + if extra > cbg.MaxLength { + return fmt.Errorf("SetArgumentsModel: map struct too large (%d)", extra) + } + + n := extra + + nameBuf := make([]byte, 17) + for i := uint64(0); i < n; i++ { + nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 8192) + if err != nil { + return err + } + + if !ok { + // Field doesn't exist on this type, so ignore it + if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { + return err + } + continue + } + + switch string(nameBuf[:nameLen]) { + // t.Weight (int64) (int64) + case "weight": + { + maj, extra, err := cr.ReadHeader() + if err != nil { + return err + } + var extraI int64 + switch maj { + case cbg.MajUnsignedInt: + extraI = int64(extra) + if extraI < 0 { + return fmt.Errorf("int64 positive overflow") + } + case cbg.MajNegativeInt: + extraI = int64(extra) + if extraI < 0 { + return fmt.Errorf("int64 negative overflow") + } + extraI = -1 - extraI + default: + return fmt.Errorf("wrong type for int64 field: %d", maj) + } + + t.Weight = int64(extraI) + } + // t.Provider (did.DID) (struct) + case "provider": + + { + + if err := t.Provider.UnmarshalCBOR(cr); err != nil { + return xerrors.Errorf("unmarshaling t.Provider: %w", err) + } + + } + // t.ReplicationWeight (int64) (int64) + case "replicationWeight": + { + maj, extra, err := cr.ReadHeader() + if err != nil { + return err + } + var extraI int64 + switch maj { + case cbg.MajUnsignedInt: + extraI = int64(extra) + if extraI < 0 { + return fmt.Errorf("int64 positive overflow") + } + case cbg.MajNegativeInt: + extraI = int64(extra) + if extraI < 0 { + return fmt.Errorf("int64 negative overflow") + } + extraI = -1 - extraI + default: + return fmt.Errorf("wrong type for int64 field: %d", maj) + } + + t.ReplicationWeight = int64(extraI) + } + + default: + // Field doesn't exist on this type, so ignore it + if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { + return err + } + } + } + + return nil +} diff --git a/pkg/capabilities/admin/provider/weight/datamodel/gen/main.go b/pkg/capabilities/admin/provider/weight/datamodel/gen/main.go new file mode 100644 index 0000000..72db3b8 --- /dev/null +++ b/pkg/capabilities/admin/provider/weight/datamodel/gen/main.go @@ -0,0 +1,19 @@ +package main + +import ( + jsg "github.com/alanshaw/dag-json-gen" + wdm "github.com/storacha/sprue/pkg/capabilities/admin/provider/weight/datamodel" + cbg "github.com/whyrusleeping/cbor-gen" +) + +func main() { + models := []any{ + wdm.SetArgumentsModel{}, + } + if err := cbg.WriteMapEncodersToFile("../cbor_gen.go", "datamodel", models...); err != nil { + panic(err) + } + if err := jsg.WriteMapEncodersToFile("../json_gen.go", "datamodel", models...); err != nil { + panic(err) + } +} diff --git a/pkg/capabilities/admin/provider/weight/datamodel/json_gen.go b/pkg/capabilities/admin/provider/weight/datamodel/json_gen.go new file mode 100644 index 0000000..fae8f59 --- /dev/null +++ b/pkg/capabilities/admin/provider/weight/datamodel/json_gen.go @@ -0,0 +1,180 @@ +// Code generated by github.com/alanshaw/dag-json-gen. DO NOT EDIT. + +package datamodel + +import ( + "errors" + "fmt" + "io" + "math" + "sort" + + jsg "github.com/alanshaw/dag-json-gen" + cid "github.com/ipfs/go-cid" +) + +var _ = cid.Undef +var _ = math.E +var _ = sort.Sort +var _ = errors.Is + +func (t *SetArgumentsModel) MarshalDagJSON(w io.Writer) error { + jw := jsg.NewDagJsonWriter(w) + if t == nil { + err := jw.WriteNull() + return err + } + if err := jw.WriteObjectOpen(); err != nil { + return err + } + written := 0 + + // t.Provider (did.DID) (struct) + if len("provider") > 8192 { + return fmt.Errorf("String in field \"provider\" was too long") + } + if err := jw.WriteString(string("provider")); err != nil { + return fmt.Errorf("\"provider\": %w", err) + } + if err := jw.WriteObjectColon(); err != nil { + return err + } + if err := t.Provider.MarshalDagJSON(jw); err != nil { + return fmt.Errorf("t.Provider: %w", err) + } + written++ + if written > 0 { + if err := jw.WriteComma(); err != nil { + return err + } + } + + // t.ReplicationWeight (int64) (int64) + if len("replicationWeight") > 8192 { + return fmt.Errorf("String in field \"replicationWeight\" was too long") + } + if err := jw.WriteString(string("replicationWeight")); err != nil { + return fmt.Errorf("\"replicationWeight\": %w", err) + } + if err := jw.WriteObjectColon(); err != nil { + return err + } + + if err := jw.WriteInt64(int64(t.ReplicationWeight)); err != nil { + return fmt.Errorf("t.ReplicationWeight: %w", err) + } + + written++ + if written > 0 { + if err := jw.WriteComma(); err != nil { + return err + } + } + + // t.Weight (int64) (int64) + if len("weight") > 8192 { + return fmt.Errorf("String in field \"weight\" was too long") + } + if err := jw.WriteString(string("weight")); err != nil { + return fmt.Errorf("\"weight\": %w", err) + } + if err := jw.WriteObjectColon(); err != nil { + return err + } + + if err := jw.WriteInt64(int64(t.Weight)); err != nil { + return fmt.Errorf("t.Weight: %w", err) + } + + written++ + if err := jw.WriteObjectClose(); err != nil { + return err + } + return nil +} +func (t *SetArgumentsModel) UnmarshalDagJSON(r io.Reader) (err error) { + *t = SetArgumentsModel{} + + jr := jsg.NewDagJsonReader(r) + defer func() { + if err == io.EOF { + err = io.ErrUnexpectedEOF + } + }() + if err := jr.ReadObjectOpen(); err != nil { + return fmt.Errorf("SetArgumentsModel: %w", err) + } + close, err := jr.PeekObjectClose() + if err != nil { + return fmt.Errorf("SetArgumentsModel: %w", err) + } + if close { + if err := jr.ReadObjectClose(); err != nil { + return fmt.Errorf("SetArgumentsModel: %w", err) + } + } else { + for i := uint64(0); i < 8192; i++ { + name, err := jr.ReadString(8192) + if err != nil { + if errors.Is(err, jsg.ErrLimitExceeded) { + return fmt.Errorf("SetArgumentsModel: string too large") + } + return fmt.Errorf("SetArgumentsModel: %w", err) + } + if err := jr.ReadObjectColon(); err != nil { + return fmt.Errorf("SetArgumentsModel: %w", err) + } + switch name { + + // t.Provider (did.DID) (struct) + case "provider": + + if err := t.Provider.UnmarshalDagJSON(jr); err != nil { + return fmt.Errorf("unmarshaling t.Provider: %w", err) + } + + // t.ReplicationWeight (int64) (int64) + case "replicationWeight": + { + + nval, err := jr.ReadNumberAsInt64() + if err != nil { + return fmt.Errorf("t.ReplicationWeight: %w", err) + } + t.ReplicationWeight = int64(nval) + + } + + // t.Weight (int64) (int64) + case "weight": + { + + nval, err := jr.ReadNumberAsInt64() + if err != nil { + return fmt.Errorf("t.Weight: %w", err) + } + t.Weight = int64(nval) + + } + default: + // Field doesn't exist on this type, so ignore it + if err := jr.DiscardType(); err != nil { + return fmt.Errorf("SetArgumentsModel: ignoring field %s: %w", name, err) + } + } + + close, err := jr.ReadObjectCloseOrComma() + if err != nil { + return fmt.Errorf("SetArgumentsModel: %w", err) + } + if close { + break + } + if i == 8192-1 { + return fmt.Errorf("SetArgumentsModel: map too large") + } + } + } + + return nil +} diff --git a/pkg/capabilities/admin/provider/weight/datamodel/set.go b/pkg/capabilities/admin/provider/weight/datamodel/set.go new file mode 100644 index 0000000..5658bf9 --- /dev/null +++ b/pkg/capabilities/admin/provider/weight/datamodel/set.go @@ -0,0 +1,9 @@ +package datamodel + +import "github.com/fil-forge/ucantone/did" + +type SetArgumentsModel struct { + Provider did.DID `cborgen:"provider" dagjsongen:"provider"` + Weight int64 `cborgen:"weight" dagjsongen:"weight"` + ReplicationWeight int64 `cborgen:"replicationWeight" dagjsongen:"replicationWeight"` +} diff --git a/pkg/capabilities/admin/provider/weight/set.go b/pkg/capabilities/admin/provider/weight/set.go new file mode 100644 index 0000000..abfbd01 --- /dev/null +++ b/pkg/capabilities/admin/provider/weight/set.go @@ -0,0 +1,24 @@ +package weight + +import ( + cdm "github.com/fil-forge/libforge/capabilities/datamodel" + "github.com/fil-forge/ucantone/ucan/delegation/policy" + "github.com/fil-forge/ucantone/validator/bindcap" + "github.com/fil-forge/ucantone/validator/capability" + wdm "github.com/storacha/sprue/pkg/capabilities/admin/provider/weight/datamodel" +) + +const SetCommand = "/provider/weight/set" + +type ( + SetArguments = wdm.SetArgumentsModel + SetOK = cdm.UnitModel +) + +var Set, _ = bindcap.New[*SetArguments]( + SetCommand, + capability.WithPolicyBuilder( + policy.GreaterThanOrEqual(".weight", 0), + policy.GreaterThanOrEqual(".replicationWeight", 0), + ), +) diff --git a/pkg/capabilities/admin/provider/weight_set.go b/pkg/capabilities/admin/provider/weight_set.go deleted file mode 100644 index f038587..0000000 --- a/pkg/capabilities/admin/provider/weight_set.go +++ /dev/null @@ -1,33 +0,0 @@ -package provider - -import ( - "github.com/ipld/go-ipld-prime/datamodel" - "github.com/storacha/go-ucanto/core/ipld" - "github.com/storacha/go-ucanto/core/result/ok" - "github.com/storacha/go-ucanto/core/schema" - "github.com/storacha/go-ucanto/did" - "github.com/storacha/go-ucanto/validator" - - "github.com/storacha/go-libstoracha/capabilities/types" -) - -const WeightSetAbility = "admin/provider/weight/set" - -type WeightSetCaveats struct { - Provider did.DID - Weight int - ReplicationWeight int -} - -func (wc WeightSetCaveats) ToIPLD() (datamodel.Node, error) { - return ipld.WrapWithRecovery(&wc, WeightSetCaveatsType(), types.Converters...) -} - -type WeightSetOk = ok.Unit - -var WeightSet = validator.NewCapability( - WeightSetAbility, - schema.DIDString(), - schema.Struct[WeightSetCaveats](WeightSetCaveatsType(), nil, types.Converters...), - validator.DefaultDerives[WeightSetCaveats], -) diff --git a/pkg/client/client.go b/pkg/client/client.go index 3187973..d65da96 100644 --- a/pkg/client/client.go +++ b/pkg/client/client.go @@ -3,282 +3,160 @@ package client import ( "context" "fmt" - "net/http" "net/url" - "strings" - "time" - - "github.com/storacha/go-libstoracha/capabilities/types" - "github.com/storacha/go-ucanto/client" - "github.com/storacha/go-ucanto/core/delegation" - "github.com/storacha/go-ucanto/core/invocation" - "github.com/storacha/go-ucanto/core/ipld" - "github.com/storacha/go-ucanto/core/receipt" - "github.com/storacha/go-ucanto/core/result" - fdm "github.com/storacha/go-ucanto/core/result/failure/datamodel" - "github.com/storacha/go-ucanto/did" - ucan_http "github.com/storacha/go-ucanto/transport/http" - "github.com/storacha/go-ucanto/ucan" - "github.com/storacha/sprue/pkg/capabilities/admin/provider" + "slices" + + "github.com/fil-forge/ucantone/client" + "github.com/fil-forge/ucantone/did" + "github.com/fil-forge/ucantone/ucan" + "github.com/fil-forge/ucantone/ucan/invocation" + providercap "github.com/storacha/sprue/pkg/capabilities/admin/provider" + weightcap "github.com/storacha/sprue/pkg/capabilities/admin/provider/weight" + "github.com/storacha/sprue/pkg/lib/ucan_client" + "go.uber.org/zap" ) -type Option func(*Client) error - -// WithServiceURL configures the URL to use when sending invocations. Unused -// when [WithConnection] option is passed. -func WithServiceURL(serviceURL string) Option { - return func(c *Client) error { - parsedURL, err := url.Parse(serviceURL) - if err != nil { - return fmt.Errorf("parsing service URL: %w", err) - } - c.serviceURL = parsedURL - return nil - } +type Client struct { + uploadServiceID did.DID + client *client.HTTPClient + signer ucan.Signer + logger *zap.Logger } -// WithHTTPClient configures the HTTP client to use when sending invocation -// requests. Unused when [WithConnection] option is passed. -func WithHTTPClient(httpClient *http.Client) Option { - return func(c *Client) error { - c.httpClient = httpClient - return nil +func New(uploadServiceID did.DID, endpoint *url.URL, signer ucan.Signer, logger *zap.Logger) (*Client, error) { + client, err := client.NewHTTP(endpoint) + if err != nil { + return nil, fmt.Errorf("creating HTTP client: %w", err) } + return NewWithClient(uploadServiceID, client, signer, logger), nil } -// WithConnection configures the client connection to use for invocations. -func WithConnection(conn client.Connection) Option { - return func(c *Client) error { - c.Connection = conn - return nil +func NewWithClient(uploadServiceID did.DID, client *client.HTTPClient, signer ucan.Signer, logger *zap.Logger) *Client { + return &Client{ + uploadServiceID: uploadServiceID, + signer: signer, + client: client, + logger: logger, } } -type Client struct { - serviceURL *url.URL - httpClient *http.Client - Connection client.Connection -} - -func New(serviceID ucan.Principal, options ...Option) (*Client, error) { - c := Client{ - httpClient: &http.Client{Timeout: 30 * time.Second}, - } - for _, opt := range options { - if err := opt(&c); err != nil { - return nil, err - } - } - if c.Connection != nil { - return &c, nil - } - if c.serviceURL == nil { - if !strings.HasPrefix(serviceID.DID().String(), "did:web:") { - return nil, fmt.Errorf("service URL must be provided if no connection is set") - } - // For did:web, we can derive the service URL from the DID by replacing - // "did:web:" with "https://". - domain := strings.TrimPrefix(serviceID.DID().String(), "did:web:") - u, err := url.Parse(fmt.Sprintf("https://%s", domain)) - if err != nil { - return nil, fmt.Errorf("parsing derived service URL: %w", err) - } - c.serviceURL = u - } - channel := ucan_http.NewChannel(c.serviceURL, ucan_http.WithClient(c.httpClient)) - conn, err := client.NewConnection(serviceID, channel) - if err != nil { - return nil, fmt.Errorf("creating connection: %w", err) +func (c *Client) AdminProviderRegister(ctx context.Context, providerID did.DID, endpoint string, options ...invocation.Option) (ucan.Receipt, error) { + if c.signer.DID() != c.uploadServiceID { + return nil, fmt.Errorf("admin operation not permitted: signer DID %s does not match upload service ID %s", c.signer.DID(), c.uploadServiceID) } - c.Connection = conn - return &c, nil -} -func (c *Client) AdminProviderRegister(ctx context.Context, signer ucan.Signer, endpoint string, proof delegation.Delegation, options ...delegation.Option) (provider.RegisterOk, error) { - inv, err := provider.Register.Invoke( - signer, - c.Connection.ID(), - c.Connection.ID().DID().String(), - provider.RegisterCaveats{ + options = slices.Clone(options) + options = append( + options, + invocation.WithAudience(c.uploadServiceID), + ) + + inv, err := providercap.Register.Invoke( + c.signer, + c.uploadServiceID, + &providercap.RegisterArguments{ + Provider: providerID, Endpoint: endpoint, - Proof: proof.Link(), }, options..., ) if err != nil { - return provider.RegisterOk{}, fmt.Errorf("invoking provider register: %w", err) - } - for b, err := range proof.Blocks() { - if err != nil { - return provider.RegisterOk{}, fmt.Errorf("iterating proof blocks: %w", err) - } - if err := inv.Attach(b); err != nil { - return provider.RegisterOk{}, fmt.Errorf("attaching proof block: %w", err) - } + return nil, fmt.Errorf("invoking provider register: %w", err) } - res, err := client.Execute(ctx, []invocation.Invocation{inv}, c.Connection) + _, rcpt, err := ucan_client.Execute[*providercap.RegisterOK](ctx, c.client, c.logger, inv) if err != nil { - return provider.RegisterOk{}, fmt.Errorf("executing invocation: %w", err) - } - - rcptLink, ok := res.Get(inv.Link()) - if !ok { - return provider.RegisterOk{}, fmt.Errorf("no receipt found for invocation in response") + return nil, fmt.Errorf("executing provider register invocation: %w", err) } + return rcpt, nil +} - reader := receipt.NewAnyReceiptReader(types.Converters...) - rcpt, err := reader.Read(rcptLink, res.Blocks()) - if err != nil { - return provider.RegisterOk{}, fmt.Errorf("reading receipt: %w", err) +func (c *Client) AdminProviderDeregister(ctx context.Context, providerID did.DID, options ...invocation.Option) (ucan.Receipt, error) { + if c.signer.DID() != c.uploadServiceID { + return nil, fmt.Errorf("admin operation not permitted: signer DID %s does not match upload service ID %s", c.signer.DID(), c.uploadServiceID) } - return result.MatchResultR2( - rcpt.Out(), - func(o ipld.Node) (provider.RegisterOk, error) { - v, err := ipld.Rebind[provider.RegisterOk](o, provider.RegisterOkType(), types.Converters...) - if err != nil { - return provider.RegisterOk{}, fmt.Errorf("binding register success: %w", err) - } - return v, nil - }, - func(x ipld.Node) (provider.RegisterOk, error) { - return provider.RegisterOk{}, fdm.Bind(x) - }, + options = slices.Clone(options) + options = append( + options, + invocation.WithAudience(c.uploadServiceID), ) -} -func (c *Client) AdminProviderDeregister(ctx context.Context, signer ucan.Signer, providerID did.DID, options ...delegation.Option) (provider.DeregisterOk, error) { - inv, err := provider.Deregister.Invoke( - signer, - c.Connection.ID(), - c.Connection.ID().DID().String(), - provider.DeregisterCaveats{ + inv, err := providercap.Deregister.Invoke( + c.signer, + c.uploadServiceID, + &providercap.DeregisterArguments{ Provider: providerID, }, options..., ) if err != nil { - return provider.DeregisterOk{}, fmt.Errorf("invoking provider deregister: %w", err) + return nil, fmt.Errorf("invoking provider deregister: %w", err) } - res, err := client.Execute(ctx, []invocation.Invocation{inv}, c.Connection) + _, rcpt, err := ucan_client.Execute[*providercap.DeregisterOK](ctx, c.client, c.logger, inv) if err != nil { - return provider.DeregisterOk{}, fmt.Errorf("executing invocation: %w", err) - } - - rcptLink, ok := res.Get(inv.Link()) - if !ok { - return provider.DeregisterOk{}, fmt.Errorf("no receipt found for invocation in response") + return nil, fmt.Errorf("executing provider deregister invocation: %w", err) } + return rcpt, nil +} - reader := receipt.NewAnyReceiptReader(types.Converters...) - rcpt, err := reader.Read(rcptLink, res.Blocks()) - if err != nil { - return provider.DeregisterOk{}, fmt.Errorf("reading receipt: %w", err) +func (c *Client) AdminProviderList(ctx context.Context, options ...invocation.Option) (*providercap.ListOK, ucan.Receipt, error) { + if c.signer.DID() != c.uploadServiceID { + return nil, nil, fmt.Errorf("admin operation not permitted: signer DID %s does not match upload service ID %s", c.signer.DID(), c.uploadServiceID) } - return result.MatchResultR2( - rcpt.Out(), - func(o ipld.Node) (provider.DeregisterOk, error) { - v, err := ipld.Rebind[provider.DeregisterOk](o, provider.DeregisterOkType(), types.Converters...) - if err != nil { - return provider.DeregisterOk{}, fmt.Errorf("binding deregister success: %w", err) - } - return v, nil - }, - func(x ipld.Node) (provider.DeregisterOk, error) { - return provider.DeregisterOk{}, fdm.Bind(x) - }, + options = slices.Clone(options) + options = append( + options, + invocation.WithAudience(c.uploadServiceID), ) -} -func (c *Client) AdminProviderList(ctx context.Context, signer ucan.Signer, options ...delegation.Option) (provider.ListOk, error) { - inv, err := provider.List.Invoke( - signer, - c.Connection.ID(), - c.Connection.ID().DID().String(), - provider.ListCaveats{}, + inv, err := providercap.List.Invoke( + c.signer, + c.uploadServiceID, + &providercap.ListArguments{}, options..., ) if err != nil { - return provider.ListOk{}, fmt.Errorf("invoking provider list: %w", err) + return nil, nil, fmt.Errorf("invoking provider list: %w", err) } - res, err := client.Execute(ctx, []invocation.Invocation{inv}, c.Connection) + listOK, rcpt, err := ucan_client.Execute[*providercap.ListOK](ctx, c.client, c.logger, inv) if err != nil { - return provider.ListOk{}, fmt.Errorf("executing invocation: %w", err) - } - - rcptLink, ok := res.Get(inv.Link()) - if !ok { - return provider.ListOk{}, fmt.Errorf("no receipt found for invocation in response") + return nil, nil, fmt.Errorf("executing provider list invocation: %w", err) } + return listOK, rcpt, nil +} - reader := receipt.NewAnyReceiptReader(types.Converters...) - rcpt, err := reader.Read(rcptLink, res.Blocks()) - if err != nil { - return provider.ListOk{}, fmt.Errorf("reading receipt: %w", err) +func (c *Client) AdminProviderWeightSet(ctx context.Context, providerID did.DID, weight int, replicationWeight int, options ...invocation.Option) (ucan.Receipt, error) { + if c.signer.DID() != c.uploadServiceID { + return nil, fmt.Errorf("admin operation not permitted: signer DID %s does not match upload service ID %s", c.signer.DID(), c.uploadServiceID) } - return result.MatchResultR2( - rcpt.Out(), - func(o ipld.Node) (provider.ListOk, error) { - v, err := ipld.Rebind[provider.ListOk](o, provider.ListOkType(), types.Converters...) - if err != nil { - return provider.ListOk{}, fmt.Errorf("binding list success: %w", err) - } - return v, nil - }, - func(x ipld.Node) (provider.ListOk, error) { - return provider.ListOk{}, fdm.Bind(x) - }, + options = slices.Clone(options) + options = append( + options, + invocation.WithAudience(c.uploadServiceID), ) -} -func (c *Client) AdminProviderWeightSet(ctx context.Context, signer ucan.Signer, providerID did.DID, weight int, replicationWeight int, options ...delegation.Option) (provider.WeightSetOk, error) { - inv, err := provider.WeightSet.Invoke( - signer, - c.Connection.ID(), - c.Connection.ID().DID().String(), - provider.WeightSetCaveats{ + inv, err := weightcap.Set.Invoke( + c.signer, + c.uploadServiceID, + &weightcap.SetArguments{ Provider: providerID, - Weight: weight, - ReplicationWeight: replicationWeight, + Weight: int64(weight), + ReplicationWeight: int64(replicationWeight), }, options..., ) if err != nil { - return provider.WeightSetOk{}, fmt.Errorf("invoking provider weight set: %w", err) - } - - res, err := client.Execute(ctx, []invocation.Invocation{inv}, c.Connection) - if err != nil { - return provider.WeightSetOk{}, fmt.Errorf("executing invocation: %w", err) + return nil, fmt.Errorf("invoking provider weight set: %w", err) } - rcptLink, ok := res.Get(inv.Link()) - if !ok { - return provider.WeightSetOk{}, fmt.Errorf("no receipt found for invocation in response") - } - - reader := receipt.NewAnyReceiptReader(types.Converters...) - rcpt, err := reader.Read(rcptLink, res.Blocks()) + _, rcpt, err := ucan_client.Execute[*weightcap.SetOK](ctx, c.client, c.logger, inv) if err != nil { - return provider.WeightSetOk{}, fmt.Errorf("reading receipt: %w", err) + return nil, fmt.Errorf("executing provider weight set invocation: %w", err) } - - return result.MatchResultR2( - rcpt.Out(), - func(o ipld.Node) (provider.WeightSetOk, error) { - v, err := ipld.Rebind[provider.WeightSetOk](o, provider.WeightSetOkType(), types.Converters...) - if err != nil { - return provider.WeightSetOk{}, fmt.Errorf("binding weight set success: %w", err) - } - return v, nil - }, - func(x ipld.Node) (provider.WeightSetOk, error) { - return provider.WeightSetOk{}, fdm.Bind(x) - }, - ) + return rcpt, nil } diff --git a/pkg/identity/identity.go b/pkg/identity/identity.go index 50df1b4..edcc665 100644 --- a/pkg/identity/identity.go +++ b/pkg/identity/identity.go @@ -9,10 +9,10 @@ import ( "os" "strings" - "github.com/storacha/go-ucanto/did" - "github.com/storacha/go-ucanto/principal" - ed25519 "github.com/storacha/go-ucanto/principal/ed25519/signer" - "github.com/storacha/go-ucanto/principal/signer" + "github.com/fil-forge/ucantone/did" + "github.com/fil-forge/ucantone/principal" + "github.com/fil-forge/ucantone/principal/ed25519" + "github.com/fil-forge/ucantone/principal/signer" ) // Identity holds the service's cryptographic identity. @@ -53,7 +53,7 @@ func (i *Identity) DID() string { // For unwrapped signers, returns the same as DID(). func (i *Identity) UnderlyingKeyDID() string { // Try to unwrap if it's a wrapped signer - if wrapped, ok := i.Signer.(signer.WrappedSigner); ok { + if wrapped, ok := i.Signer.(signer.Unwrapper); ok { return wrapped.Unwrap().DID().String() } return i.Signer.DID().String() @@ -169,5 +169,5 @@ func signerFromEd25519PEMFile(path string) (principal.Signer, error) { return nil, fmt.Errorf("no PRIVATE KEY block found in PEM file") } - return ed25519.FromRaw(*privateKey) + return ed25519.FromRaw(privateKey.Seed()) } diff --git a/pkg/indexerclient/client.go b/pkg/indexerclient/client.go index dd1407e..7be6801 100644 --- a/pkg/indexerclient/client.go +++ b/pkg/indexerclient/client.go @@ -5,244 +5,86 @@ import ( "fmt" "net/url" + assertcaps "github.com/fil-forge/libforge/capabilities/assert" + contentcaps "github.com/fil-forge/libforge/capabilities/content" + "github.com/fil-forge/ucantone/client" + "github.com/fil-forge/ucantone/did" + "github.com/fil-forge/ucantone/execution" + "github.com/fil-forge/ucantone/ipld/datamodel" + "github.com/fil-forge/ucantone/ucan" + "github.com/fil-forge/ucantone/ucan/invocation" "github.com/ipfs/go-cid" - "github.com/ipld/go-ipld-prime/datamodel" - cidlink "github.com/ipld/go-ipld-prime/linking/cid" - "github.com/ipld/go-ipld-prime/node/basicnode" - "github.com/multiformats/go-multiaddr" + "github.com/storacha/sprue/pkg/lib/ucan_client" + "github.com/storacha/sprue/pkg/lib/ucan_server" "go.uber.org/zap" - - assertcap "github.com/storacha/go-libstoracha/capabilities/assert" - claimcap "github.com/storacha/go-libstoracha/capabilities/claim" - contentcap "github.com/storacha/go-libstoracha/capabilities/space/content" - captypes "github.com/storacha/go-libstoracha/capabilities/types" - uclient "github.com/storacha/go-ucanto/client" - "github.com/storacha/go-ucanto/core/delegation" - "github.com/storacha/go-ucanto/core/invocation" - "github.com/storacha/go-ucanto/core/ipld" - "github.com/storacha/go-ucanto/core/receipt" - "github.com/storacha/go-ucanto/core/result" - "github.com/storacha/go-ucanto/did" - "github.com/storacha/go-ucanto/principal" - ucanhttp "github.com/storacha/go-ucanto/transport/http" - "github.com/storacha/go-ucanto/ucan" ) -// retrievalAuthFact implements ucan.FactBuilder for the retrievalAuth fact. -// This is used to include a retrieval delegation link in the assert/index invocation -// so the indexer can fetch the index blob from storage providers that require UCAN auth. -type retrievalAuthFact struct { - link ipld.Link -} - -func (f retrievalAuthFact) ToIPLD() (map[string]datamodel.Node, error) { - return map[string]datamodel.Node{ - "retrievalAuth": basicnode.NewLink(f.link), - }, nil -} - // Client is a UCAN client for communicating with the indexer service. type Client struct { endpoint *url.URL indexerDID did.DID - signer principal.Signer - connection uclient.Connection + signer ucan.Signer + client *client.HTTPClient logger *zap.Logger } // New creates a new indexer client. -func New(endpoint *url.URL, indexerDID did.DID, signer principal.Signer, logger *zap.Logger) (*Client, error) { - channel := ucanhttp.NewChannel(endpoint) - conn, err := uclient.NewConnection(indexerDID, channel) +func New(endpoint *url.URL, indexerDID did.DID, signer ucan.Signer, logger *zap.Logger) (*Client, error) { + client, err := client.NewHTTP(endpoint) if err != nil { - return nil, fmt.Errorf("creating connection: %w", err) + return nil, fmt.Errorf("creating HTTP client: %w", err) } return &Client{ endpoint: endpoint, indexerDID: indexerDID, signer: signer, - connection: conn, + client: client, logger: logger, }, nil } -// PublishIndexClaim sends an assert/index claim to the indexer. -// clientAuth is the space/content/retrieve delegation from the client (guppy). -// If provided, the upload service creates a fresh delegation to the indexer -// using the same caveats. The client's proof chain is NOT included to avoid -// leaking did:mailto identities to storage nodes. -func (c *Client) PublishIndexClaim(ctx context.Context, space did.DID, content, index cid.Cid, clientAuth delegation.Delegation) error { - var opts []delegation.Option - - // Re-delegate the client's retrieval auth to the indexer if provided - if clientAuth != nil { - caps := clientAuth.Capabilities() - if len(caps) == 0 { - return fmt.Errorf("no capabilities in retrieval auth delegation") - } - - // Parse the original caveats from the client's delegation - origCaveats, readErr := contentcap.RetrieveCaveatsReader.Read(caps[0].Nb()) - if readErr != nil { - return fmt.Errorf("reading retrieval caveats: %w", readErr) - } - - // Create a delegation from upload service to indexer, including the client's - // proof chain. This allows the indexer to prove authority to piri. - // - // Note: This DOES include the client's proof chain (which may contain did:mailto). - // In production, a different authorization model should be used to avoid - // leaking client identities. For this mock/test setup, the did:mailto is only - // visible to piri (the storage node), not publicly exposed. - indexerDelegation, delegateErr := contentcap.Retrieve.Delegate( - c.signer, // issuer: upload service (did:key) - c.indexerDID, // audience: indexer (did:web:indexer) - space.String(), // with: space DID (resource) - origCaveats, // same caveats (Blob, Range) from client - delegation.WithNoExpiration(), - delegation.WithProof(delegation.FromDelegation(clientAuth)), // Include client's proof chain - ) - if delegateErr != nil { - return fmt.Errorf("creating indexer delegation: %w", delegateErr) - } - - // Include the delegation in the assert/index invocation - opts = append(opts, - delegation.WithFacts([]ucan.FactBuilder{ - retrievalAuthFact{link: indexerDelegation.Link()}, - }), - delegation.WithProof(delegation.FromDelegation(indexerDelegation)), - ) - } - - // assert/* capabilities are self-issued assertions, so the resource (with) - // should be the signer's DID, not the indexer's DID - inv, err := assertcap.Index.Invoke( - c.signer, - c.indexerDID, - c.signer.DID().String(), - assertcap.IndexCaveats{ - Content: cidlink.Link{Cid: content}, - Index: cidlink.Link{Cid: index}, - }, - opts..., - ) +// PublishIndexClaim sends an /assert/index claim to the indexer. +// +// The proofStore parameter is used to build the delegation chain authorizing +// the upload service to retrieve the index blob via `/content/retrieve` command. +func (c *Client) PublishIndexClaim(ctx context.Context, space did.DID, index cid.Cid, proofStore ucan_server.ProofStore, options ...invocation.Option) (ucan.Receipt, error) { + prfs, prfLinks, err := proofStore.ProofChain(ctx, c.signer, contentcaps.RetrieveCommand, space) if err != nil { - return fmt.Errorf("creating assert/index invocation: %w", err) + return nil, fmt.Errorf("building proof chain: %w", err) } - - resp, err := uclient.Execute(ctx, []invocation.Invocation{inv}, c.connection) + attestations, err := proofStore.ProofAttestations(ctx, prfs, c.signer) if err != nil { - return fmt.Errorf("executing assert/index: %w", err) - } - - rcptLink, ok := resp.Get(inv.Link()) - if !ok { - return fmt.Errorf("receipt not found for invocation") + return nil, fmt.Errorf("building attestations: %w", err) } - - // Read receipt and check for errors - anyReader := receipt.NewAnyReceiptReader(captypes.Converters...) - anyRcpt, err := anyReader.Read(rcptLink, resp.Blocks()) + // Create a content retrieval delegation from upload service to indexer + indexerDelegation, err := contentcaps.Retrieve.Delegate(c.signer, c.indexerDID, space) if err != nil { - return fmt.Errorf("reading receipt: %w", err) - } - - _, errNode := result.Unwrap(anyRcpt.Out()) - if errNode != nil { - // Extract error details for better debugging - var errDetails string - if msgNode, lookupErr := errNode.LookupByString("message"); lookupErr == nil { - if msg, asErr := msgNode.AsString(); asErr == nil { - errDetails = msg - } - } - if errDetails == "" { - if nameNode, lookupErr := errNode.LookupByString("name"); lookupErr == nil { - if name, asErr := nameNode.AsString(); asErr == nil { - errDetails = name - } - } - } - if errDetails == "" { - errDetails = "unknown error" - } - return fmt.Errorf("assert/index failed: %s", errDetails) + return nil, fmt.Errorf("creating indexer delegation: %w", err) } - return nil -} - -// CacheLocationClaim sends a claim/cache invocation to cache a location claim with the indexer. -// This tells the indexer where content is stored (provider address). -func (c *Client) CacheLocationClaim(ctx context.Context, claim delegation.Delegation, providerAddrs []multiaddr.Multiaddr) error { - c.logger.Debug("CacheLocationClaim", - zap.String("claim", claim.Link().String()), - zap.String("issuer", c.signer.DID().String()), - zap.String("audience", c.indexerDID.String()), - zap.Int("providerAddrs", len(providerAddrs))) - - inv, err := claimcap.Cache.Invoke( + inv, err := assertcaps.Index.Invoke( c.signer, - c.indexerDID, - c.signer.DID().String(), - claimcap.CacheCaveats{ - Claim: claim.Link(), - Provider: claimcap.Provider{ - Addresses: providerAddrs, - }, - }, - delegation.WithProof(delegation.FromDelegation(claim)), + c.signer, + &assertcaps.IndexArguments{Index: index}, + invocation.WithAudience(c.indexerDID), + invocation.WithMetadata( + datamodel.Map{"retrievalAuth": append(prfLinks, indexerDelegation.Link())}, + ), ) if err != nil { - return fmt.Errorf("creating claim/cache invocation: %w", err) - } - c.logger.Debug("created invocation", zap.String("link", inv.Link().String())) - - resp, err := uclient.Execute(ctx, []invocation.Invocation{inv}, c.connection) - if err != nil { - c.logger.Error("execute error", zap.Error(err)) - return fmt.Errorf("executing claim/cache: %w", err) - } - c.logger.Debug("execute succeeded") - - rcptLink, ok := resp.Get(inv.Link()) - if !ok { - c.logger.Debug("no receipt in response") - return fmt.Errorf("receipt not found for invocation") + return nil, fmt.Errorf("creating invocation: %w", err) } - c.logger.Debug("got receipt", zap.String("link", rcptLink.String())) - // Read receipt and check for errors - anyReader := receipt.NewAnyReceiptReader(captypes.Converters...) - anyRcpt, err := anyReader.Read(rcptLink, resp.Blocks()) + _, rcpt, err := ucan_client.Execute[*assertcaps.IndexOK]( + ctx, + c.client, + c.logger, + inv, + execution.WithDelegations(prfs...), + execution.WithInvocations(attestations...), + ) if err != nil { - c.logger.Error("reading receipt error", zap.Error(err)) - return fmt.Errorf("reading receipt: %w", err) - } - - okNode, errNode := result.Unwrap(anyRcpt.Out()) - c.logger.Debug("receipt result", zap.Bool("ok", okNode != nil), zap.Bool("err", errNode != nil)) - if errNode != nil { - // Extract error details for better debugging - var errDetails string - if msgNode, lookupErr := errNode.LookupByString("message"); lookupErr == nil { - if msg, asErr := msgNode.AsString(); asErr == nil { - errDetails = msg - } - } - if errDetails == "" { - if nameNode, lookupErr := errNode.LookupByString("name"); lookupErr == nil { - if name, asErr := nameNode.AsString(); asErr == nil { - errDetails = name - } - } - } - if errDetails == "" { - errDetails = "unknown error" - } - return fmt.Errorf("claim/cache failed: %s", errDetails) + return nil, fmt.Errorf("executing assert index invocation: %w", err) } - - return nil + return rcpt, nil } diff --git a/pkg/lib/didmailto/did.go b/pkg/lib/didmailto/did.go deleted file mode 100644 index 2cff644..0000000 --- a/pkg/lib/didmailto/did.go +++ /dev/null @@ -1,63 +0,0 @@ -package didmailto - -import ( - "fmt" - "net/mail" - "net/url" - "strings" - - "github.com/storacha/go-ucanto/did" - "github.com/storacha/sprue/pkg/lib/errors" -) - -const InvalidMailtoDIDErrorName = "InvalidMailtoDID" - -var ErrInvalidMailtoDID = errors.New(InvalidMailtoDIDErrorName, "invalid mailto DID") - -// New creates a new mailto DID from an RFC 5322 formatted email address. -func New(email string) (did.DID, error) { - a, err := mail.ParseAddress(email) - if err != nil { - return did.Undef, fmt.Errorf("parsing email: %w", err) - } - at := strings.LastIndex(a.Address, "@") - var local, domain string - if at < 0 { - // This is a malformed address ("@" is required in addr-spec); - return did.Undef, fmt.Errorf("malformed email address: %s", email) - } - local, domain = a.Address[:at], a.Address[at+1:] - return did.Parse(fmt.Sprintf("did:mailto:%s:%s", url.QueryEscape(domain), url.QueryEscape(local))) -} - -// Email extracts the email address from the DID. -func Email(d did.DID) (string, error) { - if !strings.HasPrefix(d.String(), "did:mailto:") { - return "", ErrInvalidMailtoDID - } - parts := strings.Split(d.String(), ":") - emailLocal, err := url.QueryUnescape(parts[3]) - if err != nil { - return "", ErrInvalidMailtoDID - } - domain, err := url.QueryUnescape(parts[2]) - if err != nil { - return "", ErrInvalidMailtoDID - } - return fmt.Sprintf("%s@%s", emailLocal, domain), nil -} - -func Parse(str string) (did.DID, error) { - d, err := did.Parse(str) - if err != nil { - return did.Undef, err - } - if !strings.HasPrefix(d.String(), "did:mailto:") { - return did.Undef, ErrInvalidMailtoDID - } - return d, nil -} - -func Format(d did.DID) string { - return d.String() -} diff --git a/pkg/lib/didmailto/did_test.go b/pkg/lib/didmailto/did_test.go deleted file mode 100644 index 5656e70..0000000 --- a/pkg/lib/didmailto/did_test.go +++ /dev/null @@ -1,141 +0,0 @@ -package didmailto - -import ( - "testing" - - "github.com/storacha/go-ucanto/did" - "github.com/stretchr/testify/require" -) - -func TestNew(t *testing.T) { - t.Run("simple email", func(t *testing.T) { - d, err := New("user@example.com") - require.NoError(t, err) - require.Equal(t, "did:mailto:example.com:user", d.String()) - }) - - t.Run("subdomain", func(t *testing.T) { - d, err := New("user@mail.example.com") - require.NoError(t, err) - require.Equal(t, "did:mailto:mail.example.com:user", d.String()) - }) - - t.Run("missing @ returns error", func(t *testing.T) { - _, err := New("notanemail") - require.Error(t, err) - }) - - t.Run("multiple @ returns error", func(t *testing.T) { - _, err := New("a@b@c") - require.Error(t, err) - }) - - t.Run("empty string returns error", func(t *testing.T) { - _, err := New("") - require.Error(t, err) - }) -} - -func TestEmail(t *testing.T) { - t.Run("round-trips simple email", func(t *testing.T) { - d, err := New("user@example.com") - require.NoError(t, err) - email, err := Email(d) - require.NoError(t, err) - require.Equal(t, "user@example.com", email) - }) - - t.Run("round-trips local part with plus sign", func(t *testing.T) { - d, err := New("user+tag@example.com") - require.NoError(t, err) - email, err := Email(d) - require.NoError(t, err) - require.Equal(t, "user+tag@example.com", email) - }) - - t.Run("non-mailto DID returns error", func(t *testing.T) { - d, err := did.Parse("did:web:example.com") - require.NoError(t, err) - _, err = Email(d) - require.ErrorIs(t, err, ErrInvalidMailtoDID) - }) - - t.Run("round-trips unusual emails", func(t *testing.T) { - emails := []string{ - // https://gist.github.com/cjaoude/fd9910626629b53c4d25#file-gistfile1-txt-L5 - "email@example.com", - "firstname.lastname@example.com", - "email@subdomain.example.com", - "firstname+lastname@example.com", - "email@123.123.123.123", - "email@[123.123.123.123]", - // TODO: quoted addresses do not roundtrip with net/mail because the - // quotes are stripped out during parsing - // "\"email\"@example.com", - // "\"email@1\"@example.com", - "1234567890@example.com", - "email@example-one.com", - "_______@example.com", - "email@example.name", - "email@example.museum", - "email@example.co.jp", - "firstname-lastname@example.com", - // https://gist.github.com/cjaoude/fd9910626629b53c4d25#file-gistfile1-txt-L24 - // TODO: none of these parse with net/mail - // "much.”more\\ unusual”@example.com", - // "very.unusual.”@”.unusual.com@example.com", - // "very.”(),:;<>[]”.VERY.”very@\\ \"very”.unusual@strange.example.com", - } - for _, email := range emails { - t.Run(email, func(t *testing.T) { - d, err := New(email) - require.NoError(t, err) - t.Log(d.String()) - got, err := Email(d) - require.NoError(t, err) - require.Equal(t, email, got) - }) - } - }) -} - -func TestParse(t *testing.T) { - t.Run("valid mailto DID", func(t *testing.T) { - d, err := Parse("did:mailto:example.com:user") - require.NoError(t, err) - require.Equal(t, "did:mailto:example.com:user", d.String()) - }) - - t.Run("non-mailto DID returns error", func(t *testing.T) { - _, err := Parse("did:web:example.com") - require.ErrorIs(t, err, ErrInvalidMailtoDID) - }) - - t.Run("invalid DID string returns error", func(t *testing.T) { - _, err := Parse("not-a-did") - require.Error(t, err) - }) -} - -func TestFormat(t *testing.T) { - d, err := New("user@example.com") - require.NoError(t, err) - require.Equal(t, d.String(), Format(d)) -} - -func TestNewEmailRoundTrip(t *testing.T) { - emails := []string{ - "user@example.com", - "user+tag@example.com", - "first.last@sub.domain.org", - } - for _, email := range emails { - t.Run(email, func(t *testing.T) { - d, err := New(email) - require.NoError(t, err) - got, err := Email(d) - require.NoError(t, err) - require.Equal(t, email, got) - }) - } -} diff --git a/pkg/lib/errors/alias.go b/pkg/lib/errors/alias.go deleted file mode 100644 index 23ae3a2..0000000 --- a/pkg/lib/errors/alias.go +++ /dev/null @@ -1,8 +0,0 @@ -package errors - -import "errors" - -var ( - As = errors.As - Is = errors.Is -) diff --git a/pkg/lib/errors/error.go b/pkg/lib/errors/error.go deleted file mode 100644 index 924e19e..0000000 --- a/pkg/lib/errors/error.go +++ /dev/null @@ -1,40 +0,0 @@ -package errors - -import ( - "fmt" - - "github.com/ipld/go-ipld-prime/datamodel" - "github.com/ipld/go-ipld-prime/fluent" - "github.com/ipld/go-ipld-prime/node/basicnode" -) - -type ErrorModel struct { - ErrorName string - Message string -} - -// New creates an IPLD error that has a name as well as a message. -func New(name, message string, args ...any) ErrorModel { - if len(args) > 0 { - message = fmt.Sprintf(message, args...) - } - return ErrorModel{ - ErrorName: name, - Message: message, - } -} - -func (em ErrorModel) Name() string { - return em.ErrorName -} - -func (em ErrorModel) Error() string { - return em.Message -} - -func (em ErrorModel) ToIPLD() (datamodel.Node, error) { - return fluent.BuildMap(basicnode.Prototype.Map, 2, func(ma fluent.MapAssembler) { - ma.AssembleEntry("name").AssignString(em.ErrorName) - ma.AssembleEntry("message").AssignString(em.Message) - }) -} diff --git a/pkg/lib/ucan_client/execute.go b/pkg/lib/ucan_client/execute.go new file mode 100644 index 0000000..c0c063a --- /dev/null +++ b/pkg/lib/ucan_client/execute.go @@ -0,0 +1,84 @@ +package ucan_client + +import ( + "context" + "fmt" + "reflect" + + "github.com/fil-forge/ucantone/client" + edm "github.com/fil-forge/ucantone/errors/datamodel" + "github.com/fil-forge/ucantone/execution" + "github.com/fil-forge/ucantone/ipld" + "github.com/fil-forge/ucantone/ipld/codec/dagcbor" + "github.com/fil-forge/ucantone/ipld/datamodel" + "github.com/fil-forge/ucantone/result" + "github.com/fil-forge/ucantone/ucan" + "go.uber.org/zap" +) + +// Execute sends the given invocation using the provided client and decodes the +// response into the specified type. +func Execute[T dagcbor.Unmarshaler]( + ctx context.Context, + client *client.HTTPClient, + logger *zap.Logger, + inv ucan.Invocation, + options ...execution.RequestOption, +) (T, ucan.Receipt, error) { + fields := []zap.Field{ + zap.Stringer("issuer", inv.Issuer().DID()), + zap.Stringer("subject", inv.Subject().DID()), + zap.Stringer("command", inv.Command()), + zap.Any("arguments", inv.Arguments()), + } + if inv.Audience() != nil { + fields = append(fields, zap.Stringer("audience", inv.Audience().DID())) + } + if len(inv.Metadata()) > 0 { + fields = append(fields, zap.Any("metadata", inv.Metadata())) + } + if len(inv.Proofs()) > 0 { + fields = append(fields, zap.Stringers("proofs", inv.Proofs())) + } + log := logger.With(zap.Dict("invocation", fields...)) + log.Debug("executing invocation") + + var zero T + resp, err := client.Execute(execution.NewRequest(ctx, inv, options...)) + if err != nil { + log.Error("failed to execute invocation", zap.Error(err)) + return zero, nil, fmt.Errorf("executing invocation: %w", err) + } + + rcpt := resp.Receipt() + ok, err := result.MatchResultR2( + rcpt.Out(), + func(o ipld.Any) (T, error) { + var ok T + // if ok is a pointer type, then we need to create an instance of it + // because rebind requires a non-nil pointer. + typ := reflect.TypeOf(ok) + if typ.Kind() == reflect.Ptr { + ok = reflect.New(typ.Elem()).Interface().(T) + } + err := datamodel.Rebind(datamodel.NewAny(o), ok) + if err != nil { + log.Error("failed to bind invocation response", zap.Error(err)) + return zero, fmt.Errorf("binding invocation response: %w", err) + } + return ok, nil + }, + func(x ipld.Any) (T, error) { + var model edm.ErrorModel + err := datamodel.Rebind(datamodel.NewAny(x), &model) + if err != nil { + log.Error("failed to bind execution failure", zap.Error(err)) + log.Error("failed execution", zap.Any("error", x)) + return zero, fmt.Errorf("executing invocation: %v", x) + } + log.Error("failed execution", zap.String("name", model.ErrorName), zap.Error(model)) + return zero, fmt.Errorf("executing invocation: %w", model) + }, + ) + return ok, rcpt, err +} diff --git a/pkg/lib/ucan_server/email_auth.go b/pkg/lib/ucan_server/email_auth.go new file mode 100644 index 0000000..5ef555d --- /dev/null +++ b/pkg/lib/ucan_server/email_auth.go @@ -0,0 +1,96 @@ +package ucan_server + +import ( + "context" + "fmt" + + "github.com/fil-forge/libforge/capabilities/access" + "github.com/fil-forge/libforge/didmailto" + "github.com/fil-forge/ucantone/execution" + "github.com/fil-forge/ucantone/ipld" + "github.com/fil-forge/ucantone/ipld/datamodel" + "github.com/fil-forge/ucantone/result" + "github.com/fil-forge/ucantone/ucan" + "github.com/fil-forge/ucantone/ucan/container" +) + +type AccessConfirmResult struct { + Email string + Audience string + UCAN string + Meta ipld.Map +} + +// ExecBase64urlAccessConfirm executes an /access/confirm UCAN invocation +// contained in a base64url-encoded container string. Typically used by the +// email authentication flow. +func ExecBase64urlAccessConfirm(ctx context.Context, executor execution.Executor, input string) (AccessConfirmResult, error) { + inCt, err := container.Decode([]byte(input)) + if err != nil { + return AccessConfirmResult{}, fmt.Errorf("decoding UCAN container: %w", err) + } + if len(inCt.Invocations()) != 1 { + return AccessConfirmResult{}, fmt.Errorf("unexpected number of invocations found in UCAN") + } + + confirmation := inCt.Invocations()[0] + // check this is a confirmation invocation + if confirmation.Command() != access.ConfirmCommand { + return AccessConfirmResult{}, fmt.Errorf("unexpected command in invocation, expected %s but got %s", access.ConfirmCommand, confirmation.Command()) + } + + req := execution.NewRequest( + ctx, + confirmation, + execution.WithDelegations(inCt.Delegations()...), + execution.WithReceipts(inCt.Receipts()...), + ) + + res, err := executor.Execute(req) + if err != nil { + return AccessConfirmResult{}, fmt.Errorf("executing confirm task %s: %w", confirmation.Task().Link(), err) + } + + _, x := result.Unwrap(res.Receipt().Out()) + if x != nil { + return AccessConfirmResult{}, fmt.Errorf("invocation failure: %v", x) + } + + confirmArgs := access.ConfirmArguments{} + err = datamodel.Rebind(datamodel.NewAny(confirmation.Arguments()), &confirmArgs) + if err != nil { + return AccessConfirmResult{}, fmt.Errorf("binding confirmation arguments: %w", err) + } + + email, err := didmailto.Email(confirmArgs.Issuer) + if err != nil { + return AccessConfirmResult{}, fmt.Errorf("parsing account DID: %w", err) + } + + var invocations []ucan.Invocation + var delegations []ucan.Delegation + receipts := []ucan.Receipt{res.Receipt()} + if res.Metadata() != nil { + invocations = append(invocations, res.Metadata().Invocations()...) + delegations = append(delegations, res.Metadata().Delegations()...) + receipts = append(receipts, res.Metadata().Receipts()...) + } + + outCt := container.New( + container.WithInvocations(invocations...), + container.WithDelegations(delegations...), + container.WithReceipts(receipts...), + ) + + output, err := container.Encode(container.Base64urlGzip, outCt) + if err != nil { + return AccessConfirmResult{}, fmt.Errorf("encoding output UCAN container: %w", err) + } + + return AccessConfirmResult{ + Email: email, + Audience: confirmArgs.Audience.String(), + UCAN: string(output), + Meta: confirmation.Metadata(), + }, nil +} diff --git a/pkg/lib/ucan_server/events.go b/pkg/lib/ucan_server/events.go new file mode 100644 index 0000000..2f7ee0a --- /dev/null +++ b/pkg/lib/ucan_server/events.go @@ -0,0 +1,69 @@ +package ucan_server + +import ( + "context" + "fmt" + + edm "github.com/fil-forge/ucantone/errors/datamodel" + "github.com/fil-forge/ucantone/execution" + "github.com/fil-forge/ucantone/ipld/datamodel" + "github.com/fil-forge/ucantone/result" + "github.com/fil-forge/ucantone/server" + "github.com/fil-forge/ucantone/ucan" + "github.com/storacha/sprue/pkg/store/agent" + "go.uber.org/zap" +) + +type ErrorHandler struct { + Logger *zap.Logger +} + +var _ server.ResponseEncodeListener = (*ErrorHandler)(nil) + +func (l ErrorHandler) OnResponseEncode(ctx context.Context, ct ucan.Container) error { + for _, inv := range ct.Invocations() { + if r, ok := ct.Receipt(inv.Task().Link()); ok { + _, x := result.Unwrap(r.Out()) + if x != nil { + var model edm.ErrorModel + datamodel.Rebind(datamodel.NewAny(x), &model) + if model.ErrorName == execution.HandlerExecutionErrorName { + l.Logger.Error( + "handler execution error", + zap.Stringer("task", inv.Task().Link()), + zap.Stringer("command", inv.Command()), + zap.Any("args", inv.Arguments()), + zap.Error(model), + ) + } + } + } + } + return nil +} + +type AgentMessageLogger struct { + Logger *zap.Logger + AgentStore agent.Store +} + +var _ server.RequestDecodeListener = (*AgentMessageLogger)(nil) +var _ server.ResponseEncodeListener = (*AgentMessageLogger)(nil) + +func (r *AgentMessageLogger) OnRequestDecode(ctx context.Context, msg ucan.Container) error { + err := r.AgentStore.Write(ctx, msg, agent.Index(msg)) + if err != nil { + r.Logger.Error("failed to write incoming agent message to store", zap.Error(err)) + return fmt.Errorf("writing incoming agent message to agent store: %w", err) + } + return nil +} + +func (r *AgentMessageLogger) OnResponseEncode(ctx context.Context, msg ucan.Container) error { + err := r.AgentStore.Write(ctx, msg, agent.Index(msg)) + if err != nil { + r.Logger.Error("failed to write outgoing agent message to store", zap.Error(err)) + return fmt.Errorf("writing outgoing agent message to agent store: %w", err) + } + return nil +} diff --git a/pkg/lib/ucan_server/proofs.go b/pkg/lib/ucan_server/proofs.go new file mode 100644 index 0000000..9477c6d --- /dev/null +++ b/pkg/lib/ucan_server/proofs.go @@ -0,0 +1,73 @@ +package ucan_server + +import ( + "context" + "iter" + + ucanlib "github.com/fil-forge/libforge/ucan" + "github.com/fil-forge/ucantone/ucan" +) + +type ProofStore interface { + ProofChain(ctx context.Context, aud ucan.Principal, cmd ucan.Command, sub ucan.Principal) ([]ucan.Delegation, []ucan.Link, error) + ProofAttestations(ctx context.Context, proofs []ucan.Delegation, authority ucan.Principal) ([]ucan.Invocation, error) +} + +// ContainerProofStore is a proof store backed by an in-memory container. +type ContainerProofStore struct { + container ucan.Container +} + +// NewContainerProofStore creates a proof store backed by an in-memory container. +func NewContainerProofStore(ct ucan.Container) *ContainerProofStore { + return &ContainerProofStore{container: ct} +} + +func (cps *ContainerProofStore) ProofChain(ctx context.Context, aud ucan.Principal, cmd ucan.Command, sub ucan.Principal) ([]ucan.Delegation, []ucan.Link, error) { + return ucanlib.ProofChain(ctx, cps.matchDelegations, aud, cmd, sub) +} + +func (cps *ContainerProofStore) ProofAttestations(ctx context.Context, proofs []ucan.Delegation, authority ucan.Principal) ([]ucan.Invocation, error) { + return ucanlib.ProofAttestations(ctx, cps.listInvocations, proofs, authority) +} + +func (ps *ContainerProofStore) listDelegations(ctx context.Context, aud ucan.Principal, cmd ucan.Command, sub ucan.Subject) iter.Seq2[ucan.Delegation, error] { + return func(yield func(ucan.Delegation, error) bool) { + if ps.container == nil { + return + } + for _, d := range ps.container.Delegations() { + if d.Audience().DID() == aud.DID() && d.Command() == cmd && equalSubject(d.Subject(), sub) { + if !yield(d, nil) { + return + } + } + } + } +} + +func (ps *ContainerProofStore) matchDelegations(ctx context.Context, aud ucan.Principal, cmd ucan.Command, sub ucan.Subject) iter.Seq2[ucan.Delegation, error] { + return ucanlib.NewDelegationMatcher(ps.listDelegations)(ctx, aud, cmd, sub) +} + +func (ps *ContainerProofStore) listInvocations(ctx context.Context, aud ucan.Principal, cmd ucan.Command, sub ucan.Subject) iter.Seq2[ucan.Invocation, error] { + return func(yield func(ucan.Invocation, error) bool) { + if ps.container == nil { + return + } + for _, d := range ps.container.Invocations() { + if d.Audience().DID() == aud.DID() && d.Command() == cmd && equalSubject(d.Subject(), sub) { + if !yield(d, nil) { + return + } + } + } + } +} + +func equalSubject(a, b ucan.Subject) bool { + if a == nil || b == nil { + return a == nil && b == nil + } + return a.DID() == b.DID() +} diff --git a/pkg/lib/ucan_server/validation.go b/pkg/lib/ucan_server/validation.go new file mode 100644 index 0000000..f3b4e59 --- /dev/null +++ b/pkg/lib/ucan_server/validation.go @@ -0,0 +1,65 @@ +package ucan_server + +import ( + "context" + "fmt" + + "github.com/fil-forge/libforge/capabilities/ucan/attest" + "github.com/fil-forge/ucantone/ipld/datamodel" + "github.com/fil-forge/ucantone/principal" + ed_verifier "github.com/fil-forge/ucantone/principal/ed25519/verifier" + secp_verifier "github.com/fil-forge/ucantone/principal/secp256k1/verifier" + "github.com/fil-forge/ucantone/ucan" + "github.com/fil-forge/ucantone/ucan/invocation" + "github.com/fil-forge/ucantone/validator" +) + +// PrincipalParser is a [validator.PrincipalParserFunc] that enables support for +// both ed25519 and secp256k1 principals in UCANs. +func PrincipalParser(str string) (principal.Verifier, error) { + if v, err := ed_verifier.Parse(str); err == nil { + return v, nil + } + if v, err := secp_verifier.Parse(str); err == nil { + return v, nil + } + return nil, fmt.Errorf("unknown principal type: %s", str) +} + +// NewAttestationVerifier creates a [validator.NonStandardSignatureVerifierFunc] +// that validates that a delegation is attested by the given authority. +func NewAttestationVerifier(authority principal.Verifier) validator.NonStandardSignatureVerifierFunc { + return func(ctx context.Context, token ucan.Token, meta ucan.Container) error { + // We only support attestations as delegations - attested delegation MUST + // delegate to an agent DID which is then used in the invocation. + dlg, ok := token.(ucan.Delegation) + if !ok { + return fmt.Errorf("token is not a delegation") + } + for _, inv := range meta.Invocations() { + if inv.Command() != attest.ProofCommand { + continue + } + // only trust attestations we issued + if inv.Issuer().DID() != authority.DID() || inv.Subject() == nil || inv.Subject().DID() != authority.DID() { + continue + } + args := attest.ProofArguments{} + err := datamodel.Rebind(datamodel.NewAny(inv.Arguments()), &args) + if err != nil { + continue + } + // make sure the attestation is for the delegation in question + if args.Proof != dlg.Link() { + continue + } + // finally, make sure the signature is valid + ok, err := invocation.VerifySignature(inv, authority) + if !ok || err != nil { + continue + } + return nil + } + return fmt.Errorf("no valid attestation found for delegation") + } +} diff --git a/pkg/lib/ucan_server/validation_test.go b/pkg/lib/ucan_server/validation_test.go new file mode 100644 index 0000000..24b1561 --- /dev/null +++ b/pkg/lib/ucan_server/validation_test.go @@ -0,0 +1,122 @@ +package ucan_server + +import ( + "testing" + + "github.com/fil-forge/libforge/capabilities/ucan/attest" + "github.com/fil-forge/ucantone/did" + "github.com/fil-forge/ucantone/ipld/datamodel" + "github.com/fil-forge/ucantone/principal/absentee" + "github.com/fil-forge/ucantone/principal/ed25519" + "github.com/fil-forge/ucantone/ucan/container" + "github.com/fil-forge/ucantone/ucan/delegation" + "github.com/fil-forge/ucantone/ucan/invocation" + "github.com/storacha/sprue/internal/testutil" + "github.com/stretchr/testify/require" +) + +func TestNewAttestationVerifier(t *testing.T) { + authority := testutil.WebService + agent := testutil.Alice + space := testutil.Must(ed25519.Generate())(t) + other := testutil.Must(ed25519.Generate())(t) + + account := absentee.From(testutil.Must(did.Parse("did:mailto:web.mail:alice"))(t)) + + dlg, err := delegation.Delegate(account, agent, space, "/blob/add") + require.NoError(t, err) + + verify := NewAttestationVerifier(authority.Verifier()) + + t.Run("token is not a delegation", func(t *testing.T) { + inv, err := invocation.Invoke(agent, space, "/blob/add", datamodel.Map{}) + require.NoError(t, err) + + err = verify(t.Context(), inv, container.New()) + require.Error(t, err) + require.Contains(t, err.Error(), "not a delegation") + }) + + t.Run("no attestations in container", func(t *testing.T) { + err := verify(t.Context(), dlg, container.New()) + require.Error(t, err) + require.Contains(t, err.Error(), "no valid attestation") + }) + + t.Run("invocation with non-attest command is ignored", func(t *testing.T) { + inv, err := invocation.Invoke(authority, authority, "/some/other", datamodel.Map{}) + require.NoError(t, err) + + err = verify(t.Context(), dlg, container.New(container.WithInvocations(inv))) + require.Error(t, err) + require.Contains(t, err.Error(), "no valid attestation") + }) + + t.Run("attestation issued by non-authority is ignored", func(t *testing.T) { + inv, err := attest.Proof.Invoke(other, other, &attest.ProofArguments{Proof: dlg.Link()}) + require.NoError(t, err) + + err = verify(t.Context(), dlg, container.New(container.WithInvocations(inv))) + require.Error(t, err) + require.Contains(t, err.Error(), "no valid attestation") + }) + + t.Run("attestation with subject other than authority is ignored", func(t *testing.T) { + inv, err := attest.Proof.Invoke(authority, other, &attest.ProofArguments{Proof: dlg.Link()}) + require.NoError(t, err) + + err = verify(t.Context(), dlg, container.New(container.WithInvocations(inv))) + require.Error(t, err) + require.Contains(t, err.Error(), "no valid attestation") + }) + + t.Run("attestation proof for a different delegation is ignored", func(t *testing.T) { + otherDlg, err := delegation.Delegate(account, agent, space, "/blob/list") + require.NoError(t, err) + + inv, err := attest.Proof.Invoke(authority, authority, &attest.ProofArguments{Proof: otherDlg.Link()}) + require.NoError(t, err) + + err = verify(t.Context(), dlg, container.New(container.WithInvocations(inv))) + require.Error(t, err) + require.Contains(t, err.Error(), "no valid attestation") + }) + + t.Run("attestation with malformed arguments is ignored", func(t *testing.T) { + inv, err := invocation.Invoke( + authority, + authority, + attest.ProofCommand, + datamodel.Map{"unrelated": "foo"}, + ) + require.NoError(t, err) + + err = verify(t.Context(), dlg, container.New(container.WithInvocations(inv))) + require.Error(t, err) + require.Contains(t, err.Error(), "no valid attestation") + }) + + t.Run("valid attestation passes verification", func(t *testing.T) { + inv, err := attest.Proof.Invoke(authority, authority, &attest.ProofArguments{Proof: dlg.Link()}) + require.NoError(t, err) + + err = verify(t.Context(), dlg, container.New(container.WithInvocations(inv))) + require.NoError(t, err) + }) + + t.Run("valid attestation found among invalid ones", func(t *testing.T) { + untrusted, err := attest.Proof.Invoke(other, other, &attest.ProofArguments{Proof: dlg.Link()}) + require.NoError(t, err) + wrongCmd, err := invocation.Invoke(authority, authority, "/some/other", datamodel.Map{}) + require.NoError(t, err) + valid, err := attest.Proof.Invoke(authority, authority, &attest.ProofArguments{Proof: dlg.Link()}) + require.NoError(t, err) + + err = verify( + t.Context(), + dlg, + container.New(container.WithInvocations(untrusted, wrongCmd, valid)), + ) + require.NoError(t, err) + }) +} diff --git a/pkg/lib/ucans/delegations.go b/pkg/lib/ucans/delegations.go deleted file mode 100644 index bf01684..0000000 --- a/pkg/lib/ucans/delegations.go +++ /dev/null @@ -1,82 +0,0 @@ -package ucans - -import ( - "bytes" - "io" - - "github.com/ipfs/go-cid" - "github.com/multiformats/go-multibase" - "github.com/storacha/go-ucanto/core/car" - "github.com/storacha/go-ucanto/core/dag/blockstore" - "github.com/storacha/go-ucanto/core/delegation" - "github.com/storacha/go-ucanto/core/ipld" - "github.com/storacha/sprue/pkg/internal/ipldutil" -) - -// ArchiveDelegations takes a list of delegations and returns CAR file bytes. -// This is the legacy format used in w3infra. -func ArchiveDelegations(delegations ...delegation.Delegation) ([]byte, error) { - var roots []ipld.Link - blocks := map[cid.Cid]ipld.Block{} - for _, d := range delegations { - roots = append(roots, d.Link()) - for b, err := range d.Export() { - if err != nil { - return nil, err - } - root, err := ipldutil.ToCID(b.Link()) - if err != nil { - return nil, err - } - blocks[root] = b - } - } - r := car.Encode(roots, func(yield func(ipld.Block, error) bool) { - for _, b := range blocks { - if !yield(b, nil) { - return - } - } - }) - return io.ReadAll(r) -} - -// FormatDelegations takes a list of delegations and returns a string that can -// be included in a URL. -func FormatDelegations(delegations ...delegation.Delegation) (string, error) { - carBytes, err := ArchiveDelegations(delegations...) - if err != nil { - return "", err - } - return multibase.Encode(multibase.Base64url, carBytes) -} - -func ParseDelegations(s string) ([]delegation.Delegation, error) { - _, carBytes, err := multibase.Decode(s) - if err != nil { - return nil, err - } - return ExtractDelegations(carBytes) -} - -// ExtractDelegations extracts a set of delegations from a CAR file encoded -// in legacy format. -func ExtractDelegations(b []byte) ([]delegation.Delegation, error) { - roots, blocks, err := car.Decode(bytes.NewReader(b)) - if err != nil { - return nil, err - } - bs, err := blockstore.NewBlockStore(blockstore.WithBlocksIterator(blocks)) - if err != nil { - return nil, err - } - dlgs := make([]delegation.Delegation, 0, len(roots)) - for _, root := range roots { - d, err := delegation.NewDelegationView(root, bs) - if err != nil { - return nil, err - } - dlgs = append(dlgs, d) - } - return dlgs, nil -} diff --git a/pkg/piriclient/client.go b/pkg/piriclient/client.go index a5a963d..9adac4f 100644 --- a/pkg/piriclient/client.go +++ b/pkg/piriclient/client.go @@ -4,22 +4,20 @@ import ( "context" "fmt" "net/url" + "slices" "time" - blobcap "github.com/storacha/go-libstoracha/capabilities/blob" - blobreplicacap "github.com/storacha/go-libstoracha/capabilities/blob/replica" - "github.com/storacha/go-libstoracha/capabilities/types" - uclient "github.com/storacha/go-ucanto/client" - "github.com/storacha/go-ucanto/core/dag/blockstore" - "github.com/storacha/go-ucanto/core/delegation" - "github.com/storacha/go-ucanto/core/invocation" - "github.com/storacha/go-ucanto/core/ipld" - "github.com/storacha/go-ucanto/core/receipt" - "github.com/storacha/go-ucanto/core/result" - fdm "github.com/storacha/go-ucanto/core/result/failure/datamodel" - "github.com/storacha/go-ucanto/did" - ucanhttp "github.com/storacha/go-ucanto/transport/http" - "github.com/storacha/go-ucanto/ucan" + blobcap "github.com/fil-forge/libforge/capabilities/blob" + blobreplicacap "github.com/fil-forge/libforge/capabilities/blob/replica" + "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" + "github.com/fil-forge/ucantone/ucan/promise" + "github.com/ipfs/go-cid" + "github.com/storacha/sprue/pkg/lib/ucan_client" + "github.com/storacha/sprue/pkg/lib/ucan_server" "go.uber.org/zap" ) @@ -30,214 +28,103 @@ import ( // allow for retries and/or job queue delays. const replicaAllocationTTL = time.Hour * 24 -// DelegationFetcher provides an interface for fetching delegation proofs on-demand. -type DelegationFetcher interface { - // GetDelegation fetches the delegation proof for the given audience. - // Returns nil if no delegation is available (not an error condition). - GetDelegation(ctx context.Context, audience ucan.Principal) (delegation.Delegation, error) -} - // Client is a UCAN client for communicating with Piri nodes. type Client struct { - piriDID did.DID - signer ucan.Signer - connection uclient.Connection - logger *zap.Logger + piriDID did.DID + signer ucan.Signer + client *client.HTTPClient + logger *zap.Logger } // New creates a new Piri client. // The delegationFetcher is used to fetch delegation proofs on-demand for each request. func New(endpoint *url.URL, piriDID did.DID, signer ucan.Signer, logger *zap.Logger) (*Client, error) { - channel := ucanhttp.NewChannel(endpoint) - conn, err := uclient.NewConnection(piriDID, channel) + client, err := client.NewHTTP(endpoint) if err != nil { - return nil, fmt.Errorf("creating connection: %w", err) + return nil, fmt.Errorf("creating HTTP client: %w", err) } - return NewWithConnection(piriDID, signer, conn, logger), nil + return NewWithClient(piriDID, signer, client, logger), nil } -func NewWithConnection(piriDID did.DID, signer ucan.Signer, conn uclient.Connection, logger *zap.Logger) *Client { +func NewWithClient(piriDID did.DID, signer ucan.Signer, client *client.HTTPClient, logger *zap.Logger) *Client { return &Client{ - piriDID: piriDID, - signer: signer, - connection: conn, - logger: logger, + piriDID: piriDID, + signer: signer, + client: client, + logger: logger, } } -// AllocateRequest contains the parameters for a blob/allocate invocation. +// AllocateRequest contains the parameters for a /blob/allocate invocation. type AllocateRequest struct { Space did.DID Digest []byte Size uint64 - Cause ipld.Link -} - -// AllocateResponse contains the response from a blob/allocate invocation. -type AllocateResponse struct { - Size uint64 - Address *blobcap.Address -} - -// fetchDelegationOpts fetches the delegation proof and returns delegation options. -func (c *Client) fetchDelegationOpts(ctx context.Context, fetcher DelegationFetcher) ([]delegation.Option, error) { - var opts []delegation.Option - - if fetcher != nil { - c.logger.Debug("fetching delegation", zap.String("piriDID", c.piriDID.String())) - proof, err := fetcher.GetDelegation(ctx, c.signer) - if err != nil { - c.logger.Error("delegation fetch error", zap.Error(err)) - return nil, fmt.Errorf("fetching delegation: %w", err) - } - if proof != nil { - c.logger.Debug("found delegation", - zap.String("issuer", proof.Issuer().DID().String()), - zap.String("audience", proof.Audience().DID().String())) - opts = append(opts, delegation.WithProof(delegation.FromDelegation(proof))) - } else { - c.logger.Debug("no delegation found", zap.String("piriDID", c.piriDID.String())) - } - } else { - c.logger.Debug("no delegation fetcher configured") - } - - return opts, nil + Cause cid.Cid } -// Allocate sends a blob/allocate invocation to the piri node. +// Allocate sends a /blob/allocate invocation to the piri node. // Returns the response data, the invocation that was sent, and the receipt from piri. -func (c *Client) Allocate(ctx context.Context, req *AllocateRequest, fetcher DelegationFetcher) (*AllocateResponse, invocation.Invocation, receipt.AnyReceipt, error) { - // Fetch delegation fresh for each request - opts, err := c.fetchDelegationOpts(ctx, fetcher) - if err != nil { - return nil, nil, nil, err - } - - // Create the invocation - // The resource (With) must be the piri node's DID for blob/allocate - inv, err := blobcap.Allocate.Invoke( - c.signer, - c.piriDID, - c.piriDID.String(), // resource is the piri DID - blobcap.AllocateCaveats{ - Space: req.Space, - Blob: types.Blob{ - Digest: req.Digest, - Size: req.Size, - }, - Cause: req.Cause, - }, - opts..., - ) +func (c *Client) Allocate(ctx context.Context, req *AllocateRequest, proofStore ucan_server.ProofStore, options ...invocation.Option) (*blobcap.AllocateOK, ucan.Invocation, ucan.Receipt, error) { + inv, prfs, attestations, err := c.AllocateInvocation(ctx, req, proofStore, options...) if err != nil { return nil, nil, nil, fmt.Errorf("creating allocate invocation: %w", err) } - // Log invocation details - proofLinks := inv.Proofs() - blockCount := 0 - for _, blkErr := range inv.Export() { - if blkErr != nil { - continue - } - blockCount++ - } c.logger.Debug("ALLOCATE invocation created", - zap.String("issuer", inv.Issuer().DID().String()), - zap.String("audience", inv.Audience().DID().String()), - zap.Int("proofLinks", len(proofLinks)), - zap.Int("blocks", blockCount)) + zap.Stringer("issuer", inv.Issuer().DID()), + zap.Stringer("audience", inv.Audience().DID()), + zap.Int("proofs", len(prfs)), + zap.Int("attestations", len(attestations)), + ) - // Execute the invocation - resp, err := uclient.Execute(ctx, []invocation.Invocation{inv}, c.connection) + allocOK, rcpt, err := ucan_client.Execute[*blobcap.AllocateOK]( + ctx, + c.client, + c.logger, + inv, + execution.WithProofs(prfs...), + execution.WithInvocations(attestations...), + ) if err != nil { - return nil, nil, nil, fmt.Errorf("executing allocate invocation: %w", err) - } - - // Get the receipt - rcptLink, ok := resp.Get(inv.Link()) - if !ok { - return nil, nil, nil, fmt.Errorf("receipt not found for invocation") + return nil, nil, nil, err } + return allocOK, inv, rcpt, nil +} - // Read the receipt using the any reader to avoid type issues - anyReader := receipt.NewAnyReceiptReader(types.Converters...) - anyRcpt, err := anyReader.Read(rcptLink, resp.Blocks()) +// AllocateInvocation returns the invocation for the allocate request (for use in effects). +func (c *Client) AllocateInvocation(ctx context.Context, req *AllocateRequest, proofStore ucan_server.ProofStore, options ...invocation.Option) (ucan.Invocation, []ucan.Delegation, []ucan.Invocation, error) { + prfs, prfLinks, err := proofStore.ProofChain(ctx, c.signer, blobcap.AllocateCommand, req.Space) if err != nil { - return nil, nil, nil, fmt.Errorf("reading receipt: %w", err) + return nil, nil, nil, fmt.Errorf("building proof chain: %w", err) } - // Check for error response - okNode, errNode := result.Unwrap(anyRcpt.Out()) - if errNode != nil { - // Try to extract error details - var errDetails string - if msgNode, lookupErr := errNode.LookupByString("message"); lookupErr == nil { - if msg, asErr := msgNode.AsString(); asErr == nil { - errDetails = msg - } - } - if errDetails == "" { - if nameNode, lookupErr := errNode.LookupByString("name"); lookupErr == nil { - if name, asErr := nameNode.AsString(); asErr == nil { - errDetails = name - } - } - } - if errDetails == "" { - errDetails = "unknown error" - } - return nil, nil, nil, fmt.Errorf("allocate failed: %s", errDetails) - } - if okNode == nil { - return nil, nil, nil, fmt.Errorf("allocate returned nil result") - } - - // Rebind to the typed receipt - typedRcpt, err := receipt.Rebind[blobcap.AllocateOk, fdm.FailureModel]( - anyRcpt, - blobcap.AllocateOkType(), - fdm.FailureType(), - types.Converters..., - ) + attestations, err := proofStore.ProofAttestations(ctx, prfs, c.signer) if err != nil { - return nil, nil, nil, fmt.Errorf("rebinding receipt: %w", err) + return nil, nil, nil, fmt.Errorf("getting proof attestations: %w", err) } - // Extract the result - allocateOk, failErr := result.Unwrap(typedRcpt.Out()) - if (failErr != fdm.FailureModel{}) { - return nil, nil, nil, fmt.Errorf("allocate failed: %s", failErr.Message) - } - - return &AllocateResponse{ - Size: allocateOk.Size, - Address: allocateOk.Address, - }, inv, anyRcpt, nil -} - -// AllocateInvocation returns the invocation for the allocate request (for use in effects). -func (c *Client) AllocateInvocation(ctx context.Context, req *AllocateRequest, fetcher DelegationFetcher) (invocation.IssuedInvocation, error) { - opts, err := c.fetchDelegationOpts(ctx, fetcher) - if err != nil { - return nil, err - } + options = slices.Clone(options) + options = append( + options, + invocation.WithAudience(c.piriDID), + invocation.WithProofs(prfLinks...), + ) - return blobcap.Allocate.Invoke( + inv, err := blobcap.Allocate.Invoke( c.signer, - c.piriDID, - c.piriDID.String(), - blobcap.AllocateCaveats{ - Space: req.Space, - Blob: types.Blob{ - Digest: req.Digest, - Size: req.Size, - }, + req.Space, + &blobcap.AllocateArguments{ + Blob: blobcap.Blob{Digest: req.Digest, Size: req.Size}, Cause: req.Cause, }, - opts..., + options..., ) + if err != nil { + return nil, nil, nil, fmt.Errorf("creating allocate invocation: %w", err) + } + + return inv, prfs, attestations, nil } // PiriDID returns the DID of the piri node. @@ -245,267 +132,140 @@ func (c *Client) PiriDID() did.DID { return c.piriDID } -// AcceptRequest contains the parameters for a blob/accept invocation. +// AcceptRequest contains the parameters for a /blob/accept invocation. type AcceptRequest struct { Space did.DID Digest []byte Size uint64 - Put ipld.Link // Link to the http/put invocation that uploaded the blob + Put cid.Cid // Link to the /http/put task that uploaded the blob } -// AcceptResponse contains the response from a blob/accept invocation. -type AcceptResponse struct { - Site ipld.Link // Link to the location claim delegation -} - -// Accept sends a blob/accept invocation to the piri node. -func (c *Client) Accept(ctx context.Context, req *AcceptRequest, fetcher DelegationFetcher) (*AcceptResponse, invocation.Invocation, receipt.AnyReceipt, error) { - // Fetch delegation fresh for each request - opts, err := c.fetchDelegationOpts(ctx, fetcher) - if err != nil { - return nil, nil, nil, err - } - - // Use WithNoExpiration so the invocation CID is deterministic and matches - // the accept invocation created in space/blob/add for effects - opts = append(opts, delegation.WithNoExpiration()) - inv, err := blobcap.Accept.Invoke( - c.signer, - c.piriDID, - c.piriDID.String(), - blobcap.AcceptCaveats{ - Space: req.Space, - Blob: types.Blob{ - Digest: req.Digest, - Size: req.Size, - }, - Put: blobcap.Promise{ - UcanAwait: blobcap.Await{ - Selector: ".out.ok", - Link: req.Put, - }, - }, - }, - opts..., - ) +// Accept sends a /blob/accept invocation to the piri node. +func (c *Client) Accept(ctx context.Context, req *AcceptRequest, proofStore ucan_server.ProofStore, options ...invocation.Option) (*blobcap.AcceptOK, ucan.Invocation, ucan.Receipt, error) { + inv, prfs, attestations, err := c.AcceptInvocation(ctx, req, proofStore, options...) if err != nil { return nil, nil, nil, fmt.Errorf("creating accept invocation: %w", err) } - // Log invocation details - acceptProofLinks := inv.Proofs() - acceptBlockCount := 0 - for _, blkErr := range inv.Export() { - if blkErr != nil { - continue - } - acceptBlockCount++ - } c.logger.Debug("ACCEPT invocation created", - zap.String("issuer", inv.Issuer().DID().String()), - zap.String("audience", inv.Audience().DID().String()), - zap.Int("proofLinks", len(acceptProofLinks)), - zap.Int("blocks", acceptBlockCount)) + zap.Stringer("issuer", inv.Issuer().DID()), + zap.Stringer("audience", inv.Audience().DID()), + zap.Int("proofs", len(prfs)), + zap.Int("attestations", len(attestations)), + ) - // Execute the invocation - resp, err := uclient.Execute(ctx, []invocation.Invocation{inv}, c.connection) + acceptOK, rcpt, err := ucan_client.Execute[*blobcap.AcceptOK]( + ctx, + c.client, + c.logger, + inv, + execution.WithProofs(prfs...), + execution.WithInvocations(attestations...), + ) if err != nil { - return nil, nil, nil, fmt.Errorf("executing accept invocation: %w", err) - } - - // Get the receipt - rcptLink, ok := resp.Get(inv.Link()) - if !ok { - return nil, nil, nil, fmt.Errorf("receipt not found for invocation") + return nil, nil, nil, err } + return acceptOK, inv, rcpt, nil +} - // Read the receipt using the any reader - anyReader := receipt.NewAnyReceiptReader(types.Converters...) - anyRcpt, err := anyReader.Read(rcptLink, resp.Blocks()) +// AcceptInvocation returns the invocation for the accept request (for use in effects). +func (c *Client) AcceptInvocation(ctx context.Context, req *AcceptRequest, proofStore ucan_server.ProofStore, options ...invocation.Option) (ucan.Invocation, []ucan.Delegation, []ucan.Invocation, error) { + prfs, prfLinks, err := proofStore.ProofChain(ctx, c.signer, blobcap.AcceptCommand, req.Space) if err != nil { - return nil, nil, nil, fmt.Errorf("reading receipt: %w", err) - } - - // Check for error response - okNode, errNode := result.Unwrap(anyRcpt.Out()) - if errNode != nil { - var errDetails string - if msgNode, lookupErr := errNode.LookupByString("message"); lookupErr == nil { - if msg, asErr := msgNode.AsString(); asErr == nil { - errDetails = msg - } - } - if errDetails == "" { - if nameNode, lookupErr := errNode.LookupByString("name"); lookupErr == nil { - if name, asErr := nameNode.AsString(); asErr == nil { - errDetails = name - } - } - } - if errDetails == "" { - errDetails = "unknown error" - } - return nil, nil, nil, fmt.Errorf("accept failed: %s", errDetails) - } - if okNode == nil { - return nil, nil, nil, fmt.Errorf("accept returned nil result") + return nil, nil, nil, fmt.Errorf("building proof chain: %w", err) } - // Extract the site link from the ok node - var site ipld.Link - if siteNode, lookupErr := okNode.LookupByString("site"); lookupErr == nil { - if siteLink, asErr := siteNode.AsLink(); asErr == nil { - site = siteLink - } - } - - return &AcceptResponse{ - Site: site, - }, inv, anyRcpt, nil -} - -// AcceptInvocation returns the invocation for the accept request (for use in effects). -func (c *Client) AcceptInvocation(ctx context.Context, req *AcceptRequest, fetcher DelegationFetcher) (invocation.IssuedInvocation, error) { - opts, err := c.fetchDelegationOpts(ctx, fetcher) + attestations, err := proofStore.ProofAttestations(ctx, prfs, c.signer) if err != nil { - return nil, err + return nil, nil, nil, fmt.Errorf("getting proof attestations: %w", err) } - opts = append(opts, delegation.WithNoExpiration()) - return blobcap.Accept.Invoke( + options = slices.Clone(options) + options = append( + options, + invocation.WithAudience(c.piriDID), + invocation.WithProofs(prfLinks...), + ) + + inv, err := blobcap.Accept.Invoke( c.signer, - c.piriDID, - c.piriDID.String(), - blobcap.AcceptCaveats{ - Space: req.Space, - Blob: types.Blob{ - Digest: req.Digest, - Size: req.Size, - }, - Put: blobcap.Promise{ - UcanAwait: blobcap.Await{ - Selector: ".out.ok", - Link: req.Put, - }, - }, + req.Space, + &blobcap.AcceptArguments{ + Blob: blobcap.Blob{Digest: req.Digest, Size: req.Size}, + Put: promise.AwaitOK{Task: req.Put}, }, - opts..., + options..., ) + if err != nil { + return nil, nil, nil, fmt.Errorf("creating accept invocation: %w", err) + } + + return inv, prfs, attestations, nil } -// ReplicaAllocateRequest contains the parameters for a blob/replica/allocate invocation. +// ReplicaAllocateRequest contains the parameters for a /blob/replica/allocate invocation. type ReplicaAllocateRequest struct { Space did.DID Digest []byte Size uint64 - Site delegation.Delegation // Location commitment - Cause ipld.Link + Site ucan.Invocation // Location commitment + Cause cid.Cid } -// ReplicaAllocateResponse contains the response from a blob/replica/allocate invocation. -type ReplicaAllocateResponse struct { - // Size is the number of bytes allocated for the Blob. - Size uint64 - // Site resolves to an additional location for the blob. - // The selector MUST be ".out.ok.site" i.e. [AllocateSiteSelector] and it - // links to a receipt of a "blob/replica/transfer" task. - Site types.Promise - // Transfer is the invocation referenced in the promise, which is included in - // the allocation response. - Transfer invocation.Invocation -} - -// ReplicaAllocate sends a blob/replica/allocate invocation to the piri node. +// ReplicaAllocate sends a /blob/replica/allocate invocation to the piri node. // Returns the response data, the invocation that was sent, and the receipt from // piri. It returns an error if the receipt contains a failure result. -func (c *Client) ReplicaAllocate(ctx context.Context, req *ReplicaAllocateRequest, fetcher DelegationFetcher) (*ReplicaAllocateResponse, invocation.Invocation, receipt.AnyReceipt, error) { - opts, err := c.fetchDelegationOpts(ctx, fetcher) +func (c *Client) ReplicaAllocate(ctx context.Context, req *ReplicaAllocateRequest, proofStore ucan_server.ProofStore, options ...invocation.Option) (*blobreplicacap.AllocateOK, ucan.Invocation, ucan.Receipt, error) { + prfs, prfLinks, err := proofStore.ProofChain(ctx, c.signer, blobreplicacap.AllocateCommand, req.Space) if err != nil { - return nil, nil, nil, err + return nil, nil, nil, fmt.Errorf("building proof chain: %w", err) } - // We set a reasonably large expiration as replication nodes use the - // invocation as proof for obtaining a retrieval delegation, and we want to - // allow for retries and/or job queue delays. - exp := time.Now().Add(replicaAllocationTTL).Unix() - opts = append(opts, delegation.WithExpiration(int(exp))) + attestations, err := proofStore.ProofAttestations(ctx, prfs, c.signer) + if err != nil { + return nil, nil, nil, fmt.Errorf("getting proof attestations: %w", err) + } + + options = slices.Clone(options) + options = append( + options, + invocation.WithAudience(c.piriDID), + invocation.WithProofs(prfLinks...), + // We set a reasonably large expiration as replication nodes use the + // invocation as proof for obtaining a retrieval delegation, and we want to + // allow for retries and/or job queue delays. + invocation.WithExpiration(uint64(time.Now().Add(replicaAllocationTTL).Unix())), + ) inv, err := blobreplicacap.Allocate.Invoke( c.signer, - c.piriDID, - c.piriDID.String(), // resource is the piri DID - blobreplicacap.AllocateCaveats{ - Space: req.Space, - Blob: types.Blob{ - Digest: req.Digest, - Size: req.Size, - }, + req.Space, + &blobreplicacap.AllocateArguments{ + Blob: blobreplicacap.Blob{Digest: req.Digest, Size: req.Size}, Site: req.Site.Link(), Cause: req.Cause, }, - opts..., + options..., ) if err != nil { return nil, nil, nil, fmt.Errorf("creating replica allocate invocation: %w", err) } - // attach the location commitment to the allocation invocation - for b, err := range req.Site.Blocks() { - if err != nil { - return nil, nil, nil, fmt.Errorf("iterating location commitment blocks: %w", err) - } - if err := inv.Attach(b); err != nil { - return nil, nil, nil, fmt.Errorf("attaching location commitment block: %w", err) - } - } - c.logger.Debug("REPLICA ALLOCATE invocation created", zap.Stringer("issuer", inv.Issuer().DID()), zap.Stringer("audience", inv.Audience().DID()), zap.Int("proofs", len(inv.Proofs()))) - resp, err := uclient.Execute(ctx, []invocation.Invocation{inv}, c.connection) - if err != nil { - return nil, nil, nil, fmt.Errorf("executing replica allocate invocation: %w", err) - } - - rcptLink, ok := resp.Get(inv.Link()) - if !ok { - return nil, nil, nil, fmt.Errorf("receipt not found for invocation") - } - - reader := receipt.NewAnyReceiptReader(types.Converters...) - rcpt, err := reader.Read(rcptLink, resp.Blocks()) - if err != nil { - return nil, nil, nil, fmt.Errorf("reading receipt: %w", err) - } - - o, x := result.Unwrap(rcpt.Out()) - if x != nil { - return nil, nil, nil, fmt.Errorf("allocate failed: %s", fdm.Bind(x).Message) - } - - allocateOk, err := ipld.Rebind[blobreplicacap.AllocateOk](o, blobreplicacap.AllocateOkType(), types.Converters...) - if err != nil { - return nil, nil, nil, fmt.Errorf("rebinding receipt: %w", err) - } - - if allocateOk.Site.UcanAwait.Selector != blobreplicacap.AllocateSiteSelector { - return nil, nil, nil, fmt.Errorf("unexpected site selector: %s", allocateOk.Site.UcanAwait.Selector) - } - - br, err := blockstore.NewBlockReader(blockstore.WithBlocksIterator(rcpt.Blocks())) - if err != nil { - return nil, nil, nil, fmt.Errorf("creating block reader: %w", err) - } - transfer, err := invocation.NewInvocationView(allocateOk.Site.UcanAwait.Link, br) + allocOK, rcpt, err := ucan_client.Execute[*blobreplicacap.AllocateOK]( + ctx, + c.client, + c.logger, + inv, + execution.WithProofs(prfs...), + execution.WithInvocations(attestations...), + ) if err != nil { - return nil, nil, nil, fmt.Errorf("creating transfer invocation view: %w", err) + return nil, nil, nil, err } - - return &ReplicaAllocateResponse{ - Size: allocateOk.Size, - Site: allocateOk.Site, - Transfer: transfer, - }, inv, rcpt, nil + return allocOK, inv, rcpt, nil } diff --git a/pkg/piriclient/provider.go b/pkg/piriclient/provider.go index c15ed40..e878c4d 100644 --- a/pkg/piriclient/provider.go +++ b/pkg/piriclient/provider.go @@ -3,7 +3,7 @@ package piriclient import ( "net/url" - "github.com/storacha/go-ucanto/ucan" + "github.com/fil-forge/ucantone/ucan" "go.uber.org/zap" ) diff --git a/pkg/provisioning/service.go b/pkg/provisioning/service.go index 4d748e1..205cec0 100644 --- a/pkg/provisioning/service.go +++ b/pkg/provisioning/service.go @@ -6,13 +6,12 @@ import ( "fmt" "slices" + "github.com/fil-forge/ucantone/did" + "github.com/fil-forge/ucantone/errors" + "github.com/fil-forge/ucantone/ipld/codec/dagcbor" + "github.com/fil-forge/ucantone/ipld/datamodel" "github.com/ipfs/go-cid" - "github.com/ipld/go-ipld-prime/codec/dagcbor" - basicnode "github.com/ipld/go-ipld-prime/node/basic" "github.com/multiformats/go-multihash" - "github.com/storacha/go-ucanto/core/ipld/codec/cbor" - "github.com/storacha/go-ucanto/did" - "github.com/storacha/sprue/pkg/lib/errors" "github.com/storacha/sprue/pkg/store/consumer" "github.com/storacha/sprue/pkg/store/subscription" ) @@ -79,9 +78,9 @@ func (s *Service) ListServiceProviders(ctx context.Context, space SpaceDID) ([]S } // Provision provisions a service provider for a consumer (space) on behalf of -// a customer (account). It may return [customer.ErrCustomerNotFound] -// if the customer does not exist and [consumer.ErrConsumerExists] if the -// consumer is already provisioned for the provider. +// a customer (account). It may return [ErrProviderNotAllowed] if the requested +// provider is not on the list, and [consumer.ErrConsumerExists] if the consumer +// is already provisioned for the provider. func (s *Service) Provision(ctx context.Context, customer AccountDID, consumer SpaceDID, provider ServiceDID, cause cid.Cid) (SubscriptionID, error) { // Ensure the provider is allowed. if !slices.ContainsFunc(s.providers, func(p ServiceDID) bool { @@ -109,28 +108,14 @@ func (s *Service) Provision(ctx context.Context, customer AccountDID, consumer S } func NewSubscriptionID(consumer SpaceDID) (string, error) { - nb := basicnode.Prototype.Map.NewBuilder() - ma, err := nb.BeginMap(1) - if err != nil { - return "", err - } - na, err := ma.AssembleEntry("consumer") - if err != nil { - return "", err - } - if err := na.AssignString(consumer.String()); err != nil { - return "", err - } - if err := ma.Finish(); err != nil { - return "", err - } + model := datamodel.Map{"consumer": consumer.String()} var buf bytes.Buffer - if err := dagcbor.Encode(nb.Build(), &buf); err != nil { + if err := model.MarshalCBOR(&buf); err != nil { return "", err } c, err := cid.Prefix{ Version: 1, - Codec: cbor.Code, + Codec: dagcbor.Code, MhType: multihash.SHA2_256, MhLength: -1, }.Sum(buf.Bytes()) diff --git a/pkg/provisioning/service_test.go b/pkg/provisioning/service_test.go index 6d3395f..56fdae4 100644 --- a/pkg/provisioning/service_test.go +++ b/pkg/provisioning/service_test.go @@ -4,9 +4,9 @@ import ( "context" "testing" - "github.com/storacha/go-ucanto/did" + "github.com/fil-forge/libforge/didmailto" + "github.com/fil-forge/ucantone/did" "github.com/storacha/sprue/internal/testutil" - "github.com/storacha/sprue/pkg/lib/didmailto" "github.com/storacha/sprue/pkg/provisioning" "github.com/storacha/sprue/pkg/store/consumer" consumermemory "github.com/storacha/sprue/pkg/store/consumer/memory" diff --git a/pkg/routing/service.go b/pkg/routing/service.go index 8c4aea3..63ffb65 100644 --- a/pkg/routing/service.go +++ b/pkg/routing/service.go @@ -6,11 +6,10 @@ import ( "net/url" "slices" - "github.com/storacha/go-libstoracha/capabilities/types" - "github.com/storacha/go-libstoracha/digestutil" - "github.com/storacha/go-ucanto/core/delegation" - "github.com/storacha/go-ucanto/ucan" - "github.com/storacha/sprue/pkg/lib/errors" + "github.com/fil-forge/libforge/capabilities/blob" + "github.com/fil-forge/libforge/digestutil" + "github.com/fil-forge/ucantone/errors" + "github.com/fil-forge/ucantone/ucan" "github.com/storacha/sprue/pkg/store" storageprovider "github.com/storacha/sprue/pkg/store/storage_provider" "go.uber.org/zap" @@ -39,12 +38,6 @@ func WithExclusions(providers ...ucan.Principal) SelectOption { type StorageProviderInfo struct { ID ucan.Principal Endpoint url.URL - // FIXME: the client should authorize the upload service to blob/allocate and - // blob/accept in the blob/add invocation. The upload service should use that - // delegation to authorize allocate and accept calls to the storage provider. - // This removes the need for storage providers to grant love lived delegations - // to the upload service. We will fix this in UCAN 1.0. - Proof delegation.Delegation } type Service struct { @@ -69,14 +62,13 @@ func (s *Service) GetProviderInfo(ctx context.Context, provider ucan.Principal) return StorageProviderInfo{ ID: rec.Provider, Endpoint: rec.Endpoint, - Proof: rec.Proof, }, nil } // SelectStorageProvider selects a candidate for blob allocation from the // current list of available storage nodes. It may return // [ErrCandidateUnavailable] if no candidates are available. -func (s *Service) SelectStorageProvider(ctx context.Context, blob types.Blob, options ...SelectOption) (StorageProviderInfo, error) { +func (s *Service) SelectStorageProvider(ctx context.Context, blob blob.Blob, options ...SelectOption) (StorageProviderInfo, error) { cfg := &selectCfg{} for _, option := range options { option(cfg) @@ -113,14 +105,13 @@ func (s *Service) SelectStorageProvider(ctx context.Context, blob types.Blob, op return StorageProviderInfo{ ID: selected.Provider, Endpoint: selected.Endpoint, - Proof: selected.Proof, }, nil } // SelectReplicationProvider selects a candidate for blob allocation from the // current list of available storage nodes, excluding the primary node. It may // return [ErrCandidateUnavailable] if no candidates are available. -func (s *Service) SelectReplicationProvider(ctx context.Context, primary ucan.Principal, blob types.Blob, options ...SelectOption) (StorageProviderInfo, error) { +func (s *Service) SelectReplicationProvider(ctx context.Context, primary ucan.Principal, blob blob.Blob, options ...SelectOption) (StorageProviderInfo, error) { cfg := &selectCfg{} for _, option := range options { option(cfg) @@ -144,7 +135,6 @@ func (s *Service) SelectReplicationProvider(ctx context.Context, primary ucan.Pr return StorageProviderInfo{ ID: selected.Provider, Endpoint: selected.Endpoint, - Proof: selected.Proof, }, nil } diff --git a/pkg/routing/service_test.go b/pkg/routing/service_test.go index 05df6bb..c652b34 100644 --- a/pkg/routing/service_test.go +++ b/pkg/routing/service_test.go @@ -4,9 +4,7 @@ import ( "net/url" "testing" - "github.com/storacha/go-libstoracha/capabilities/types" - "github.com/storacha/go-ucanto/core/delegation" - "github.com/storacha/go-ucanto/ucan" + "github.com/fil-forge/libforge/capabilities/blob" "github.com/storacha/sprue/internal/testutil" "github.com/storacha/sprue/pkg/routing" storageprovider "github.com/storacha/sprue/pkg/store/storage_provider" @@ -20,15 +18,7 @@ func addProvider(t *testing.T, store *spmemory.Store, weight int, replicationWei ctx := t.Context() storageProvider := testutil.RandomSigner(t) endpoint := testutil.Must(url.Parse("https://piri.example.com"))(t) - proof, err := delegation.Delegate( - storageProvider, - testutil.WebService, - []ucan.Capability[ucan.NoCaveats]{ - ucan.NewCapability("blob/allocate", storageProvider.DID().String(), ucan.NoCaveats{}), - }, - ) - require.NoError(t, err) - err = store.Put(ctx, *endpoint, proof, weight, replicationWeight) + err := store.Put(ctx, storageProvider.DID(), *endpoint, weight, replicationWeight) require.NoError(t, err) rec, err := store.Get(ctx, storageProvider.DID()) require.NoError(t, err) @@ -63,7 +53,7 @@ func TestGetProviderInfo(t *testing.T) { func TestSelectStorageProvider(t *testing.T) { logger := zaptest.NewLogger(t) ctx := t.Context() - blob := types.Blob{Size: 1024} + blob := blob.Blob{Size: 1024} t.Run("no providers", func(t *testing.T) { store := spmemory.New() @@ -136,7 +126,7 @@ func TestSelectStorageProvider(t *testing.T) { func TestSelectReplicationProvider(t *testing.T) { logger := zaptest.NewLogger(t) ctx := t.Context() - blob := types.Blob{Size: 1024} + blob := blob.Blob{Size: 1024} t.Run("excludes primary", func(t *testing.T) { store := spmemory.New() diff --git a/pkg/service/handlers/access_authorize.go b/pkg/service/handlers/access_authorize.go deleted file mode 100644 index 81be8e3..0000000 --- a/pkg/service/handlers/access_authorize.go +++ /dev/null @@ -1,199 +0,0 @@ -package handlers - -import ( - "context" - "fmt" - "net/url" - "time" - - "go.uber.org/zap" - - "github.com/ipld/go-ipld-prime/datamodel" - "github.com/storacha/go-libstoracha/capabilities/access" - "github.com/storacha/go-ucanto/core/delegation" - "github.com/storacha/go-ucanto/core/invocation" - "github.com/storacha/go-ucanto/core/receipt/fx" - "github.com/storacha/go-ucanto/core/result" - "github.com/storacha/go-ucanto/core/result/failure" - "github.com/storacha/go-ucanto/did" - "github.com/storacha/go-ucanto/server" - "github.com/storacha/go-ucanto/ucan" - "github.com/storacha/sprue/internal/config" - "github.com/storacha/sprue/pkg/identity" - "github.com/storacha/sprue/pkg/lib/didmailto" - "github.com/storacha/sprue/pkg/lib/errors" - "github.com/storacha/sprue/pkg/lib/ucans" - "github.com/storacha/sprue/pkg/mailer" -) - -const ( - InvalidAuthorizationAccountErrorName = "InvalidAuthorizationAccount" - InvalidAuthorizationAudienceErrorName = "InvalidAuthorizationAudience" -) - -// Standard email flow - create confirmation delegation and send email -// We allow granting access within the next 15 minutes -const confirmationTTL = time.Minute * 15 - -var ( - ErrMissingAuthorizationAccount = errors.New(InvalidAuthorizationAccountErrorName, "missing authorization account DID") - ErrInvalidAuthorizationAccount = errors.New(InvalidAuthorizationAccountErrorName, "invalid authorization account DID") - ErrInvalidAuthorizationAudience = errors.New(InvalidAuthorizationAudienceErrorName, "invalid authorization audience DID") -) - -// WithAccessAuthorizeMethod registers the access/authorize handler. -func WithAccessAuthorizeMethod(serverCfg config.ServerConfig, id *identity.Identity, mailer mailer.Mailer, logger *zap.Logger) server.Option { - return server.WithServiceMethod( - access.AuthorizeAbility, - server.Provide( - access.Authorize, - AccessAuthorizeHandler(serverCfg, id, mailer, logger), - ), - ) -} - -func AccessAuthorizeHandler(serverCfg config.ServerConfig, id *identity.Identity, mailer mailer.Mailer, logger *zap.Logger) server.HandlerFunc[access.AuthorizeCaveats, access.AuthorizeOk, failure.IPLDBuilderFailure] { - log := logger.With(zap.String("handler", access.AuthorizeAbility)) - return func(ctx context.Context, - cap ucan.Capability[access.AuthorizeCaveats], - inv invocation.Invocation, - iCtx server.InvocationContext, - ) (result.Result[access.AuthorizeOk, failure.IPLDBuilderFailure], fx.Effects, error) { - // Get the account DID from the caveats - if cap.Nb().Iss == nil { - log.Warn("missing account") - return result.Error[access.AuthorizeOk, failure.IPLDBuilderFailure](ErrMissingAuthorizationAccount), nil, nil - } - account, err := didmailto.Parse(*cap.Nb().Iss) - if err != nil { - log.Warn("invalid account", zap.String("account", *cap.Nb().Iss)) - return result.Error[access.AuthorizeOk, failure.IPLDBuilderFailure]( - errors.New(InvalidAuthorizationAccountErrorName, "invalid authorization account DID: %v", err), - ), nil, nil - } - // we should be able to extract the email from the DID since we just - // parsed it as a did:mailto: - email, err := didmailto.Email(account) - if err != nil { - return nil, nil, fmt.Errorf("extracting email from mailto DID: %w", err) - } - audience, err := did.Parse(cap.With()) - if err != nil { - log.Warn("invalid audience", zap.String("audience", cap.With())) - return result.Error[access.AuthorizeOk, failure.IPLDBuilderFailure]( - errors.New(InvalidAuthorizationAudienceErrorName, "invalid authorization audience DID: %v", err), - ), nil, nil - } - - agent := inv.Issuer().DID() - log := log.With( - zap.Stringer("agent", agent), - zap.Stringer("account", account), - zap.Stringer("audience", audience), - ) - log.Debug("authorizing access") - - exp := int(time.Now().Add(confirmationTTL).Unix()) - - // We issue `access/confirm` invocation which will - // get embedded in the URL that we send to the user. When user clicks the - // link we'll get this delegation back in the `/validate-email` endpoint - // which will allow us to verify that it was the user who clicked the link - // and not some attacker impersonating the user. We will know that because - // the `with` field is our service DID and only private key holder is able - // to issue such delegation. - // - // We limit lifetime of this UCAN to 15 minutes to reduce the attack - // surface where an attacker could attempt concurrent authorization - // request in attempt confuse a user into clicking the wrong link. - confirmation, err := access.Confirm.Invoke( - id.Signer, - // audience same as issuer because this is a service invocation - // that will get handled by access/confirm handler - // but only if the receiver of this email wants it to be - id.Signer.DID(), - id.DID(), - // We link to the authorization request so that this attestation can - // not be used to authorize a different request. - access.ConfirmCaveats{ - // we copy request details and set the `aud` field to the agent DID - // that requested the authorization. - Iss: account, - Aud: audience, - Att: cap.Nb().Att, - // Link to the invocation that requested the authorization. - Cause: inv.Link(), - }, - delegation.WithExpiration(exp), - // we copy the facts in so that information can be passed - // from the invoker of this capability to the invoker of the confirm - // capability - we use this, for example, to let bsky.storage users - // specify that they should be redirected back to bsky.storage after - // completing the Stripe plan selection flow - delegation.WithFacts(toFactBuilders(inv.Facts())), - ) - if err != nil { - log.Error("failed to create confirmation delegation", zap.Error(err)) - return nil, nil, fmt.Errorf("creating confirmation delegation: %w", err) - } - - confirmationStr, err := ucans.FormatDelegations(confirmation) - if err != nil { - log.Error("failed to format confirmation", zap.Error(err)) - return nil, nil, fmt.Errorf("formatting confirmation: %w", err) - } - - pubUrlStr := serverCfg.PublicURL - if pubUrlStr == "" { - pubUrlStr = fmt.Sprintf("http://%s:%d", serverCfg.Host, serverCfg.Port) - } - validationURL, err := url.Parse(fmt.Sprintf("%s/validate-email?ucan=%s&mode=authorize", pubUrlStr, confirmationStr)) - if err != nil { - log.Error("failed to parse validation URL", zap.Error(err)) - return nil, nil, fmt.Errorf("parsing validation URL: %w", err) - } - - err = mailer.SendValidation(ctx, email, *validationURL) - if err != nil { - log.Error("failed to send validation email", zap.Error(err)) - return nil, nil, fmt.Errorf("sending validation email: %w", err) - } - - ok := result.Ok[access.AuthorizeOk, failure.IPLDBuilderFailure](access.AuthorizeOk{ - // link to this authorization request - Request: inv.Link(), - // let client know when the confirmation will expire - Expiration: exp, - }) - - // link to the authorization confirmation so it could be used to lookup - // the delegation by the authorization request. - join := fx.NewEffects(fx.WithJoin(fx.FromLink(confirmation.Root().Link()))) - - return ok, join, nil - } -} - -type anyFactBuilder struct { - fct ucan.Fact -} - -func (afb anyFactBuilder) ToIPLD() (map[string]datamodel.Node, error) { - nodeFacts := make(map[string]datamodel.Node, len(afb.fct)) - for k, v := range afb.fct { - if node, ok := v.(datamodel.Node); ok { - nodeFacts[k] = node - } else { - return nil, fmt.Errorf("fact %q is not an IPLD node", k) - } - } - return nodeFacts, nil -} - -func toFactBuilders(fcts []ucan.Fact) []ucan.FactBuilder { - builders := make([]ucan.FactBuilder, len(fcts)) - for i, fct := range fcts { - builders[i] = anyFactBuilder{fct: fct} - } - return builders -} diff --git a/pkg/service/handlers/access_authorize_test.go b/pkg/service/handlers/access_authorize_test.go deleted file mode 100644 index 9c81ff2..0000000 --- a/pkg/service/handlers/access_authorize_test.go +++ /dev/null @@ -1,220 +0,0 @@ -package handlers - -import ( - "context" - "errors" - "net/url" - "testing" - - "github.com/storacha/go-libstoracha/capabilities/access" - "github.com/storacha/go-ucanto/core/invocation" - "github.com/storacha/go-ucanto/core/result" - "github.com/storacha/go-ucanto/ucan" - "github.com/storacha/sprue/internal/config" - "github.com/storacha/sprue/pkg/identity" - "github.com/stretchr/testify/require" - "go.uber.org/zap/zaptest" -) - -type mockMailer struct { - lastTo string - lastURL url.URL - err error -} - -func (m *mockMailer) SendValidation(ctx context.Context, to string, validationURL url.URL) error { - m.lastTo = to - m.lastURL = validationURL - return m.err -} - -func newTestIdentity(t *testing.T) *identity.Identity { - t.Helper() - id, err := identity.New("") - require.NoError(t, err) - return id -} - -func TestAccessAuthorizeHandler(t *testing.T) { - logger := zaptest.NewLogger(t) - serverCfg := config.ServerConfig{ - Host: "localhost", - Port: 8080, - PublicURL: "http://localhost:8080", - } - - t.Run("success", func(t *testing.T) { - id := newTestIdentity(t) - m := &mockMailer{} - handler := AccessAuthorizeHandler(serverCfg, id, m, logger) - - iss := "did:mailto:example.com:alice" - cap := ucan.NewCapability( - access.AuthorizeAbility, - id.DID(), - access.AuthorizeCaveats{ - Iss: &iss, - Att: []access.CapabilityRequest{{Can: "*"}}, - }, - ) - - agent, err := identity.New("") - require.NoError(t, err) - - inv, err := invocation.Invoke(agent.Signer, id.Signer, cap) - require.NoError(t, err) - - res, effects, err := handler(context.Background(), cap, inv, nil) - require.NoError(t, err) - require.NotNil(t, effects) - - ok, fail := result.Unwrap(res) - require.Nil(t, fail) - require.Equal(t, inv.Link(), ok.Request) - require.NotZero(t, ok.Expiration) - - require.Equal(t, "alice@example.com", m.lastTo) - require.Contains(t, m.lastURL.String(), "/validate-email") - require.Contains(t, m.lastURL.Query().Get("mode"), "authorize") - }) - - t.Run("missing account", func(t *testing.T) { - id := newTestIdentity(t) - m := &mockMailer{} - handler := AccessAuthorizeHandler(serverCfg, id, m, logger) - - cap := ucan.NewCapability( - access.AuthorizeAbility, - id.DID(), - access.AuthorizeCaveats{ - Iss: nil, - Att: []access.CapabilityRequest{{Can: "*"}}, - }, - ) - - agent, err := identity.New("") - require.NoError(t, err) - - inv, err := invocation.Invoke(agent.Signer, id.Signer, cap) - require.NoError(t, err) - - res, _, err := handler(context.Background(), cap, inv, nil) - require.NoError(t, err) - - _, fail := result.Unwrap(res) - require.NotNil(t, fail) - }) - - t.Run("invalid account DID", func(t *testing.T) { - id := newTestIdentity(t) - m := &mockMailer{} - handler := AccessAuthorizeHandler(serverCfg, id, m, logger) - - iss := "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK" - cap := ucan.NewCapability( - access.AuthorizeAbility, - id.DID(), - access.AuthorizeCaveats{ - Iss: &iss, - Att: []access.CapabilityRequest{{Can: "*"}}, - }, - ) - - agent, err := identity.New("") - require.NoError(t, err) - - inv, err := invocation.Invoke(agent.Signer, id.Signer, cap) - require.NoError(t, err) - - res, _, err := handler(context.Background(), cap, inv, nil) - require.NoError(t, err) - - _, fail := result.Unwrap(res) - require.NotNil(t, fail) - }) - - t.Run("invalid audience DID", func(t *testing.T) { - id := newTestIdentity(t) - m := &mockMailer{} - handler := AccessAuthorizeHandler(serverCfg, id, m, logger) - - iss := "did:mailto:example.com:alice" - cap := ucan.NewCapability( - access.AuthorizeAbility, - "not-a-did", - access.AuthorizeCaveats{ - Iss: &iss, - Att: []access.CapabilityRequest{{Can: "*"}}, - }, - ) - - agent, err := identity.New("") - require.NoError(t, err) - - inv, err := invocation.Invoke(agent.Signer, id.Signer, cap) - require.NoError(t, err) - - res, _, err := handler(context.Background(), cap, inv, nil) - require.NoError(t, err) - - _, fail := result.Unwrap(res) - require.NotNil(t, fail) - }) - - t.Run("mailer error", func(t *testing.T) { - id := newTestIdentity(t) - m := &mockMailer{err: errors.New("smtp failure")} - handler := AccessAuthorizeHandler(serverCfg, id, m, logger) - - iss := "did:mailto:example.com:alice" - cap := ucan.NewCapability( - access.AuthorizeAbility, - id.DID(), - access.AuthorizeCaveats{ - Iss: &iss, - Att: []access.CapabilityRequest{{Can: "*"}}, - }, - ) - - agent, err := identity.New("") - require.NoError(t, err) - - inv, err := invocation.Invoke(agent.Signer, id.Signer, cap) - require.NoError(t, err) - - _, _, err = handler(context.Background(), cap, inv, nil) - require.Error(t, err) - require.Contains(t, err.Error(), "sending validation email") - }) - - t.Run("public URL fallback", func(t *testing.T) { - id := newTestIdentity(t) - m := &mockMailer{} - cfgNoPublicURL := config.ServerConfig{ - Host: "myhost", - Port: 9090, - } - handler := AccessAuthorizeHandler(cfgNoPublicURL, id, m, logger) - - iss := "did:mailto:example.com:bob" - cap := ucan.NewCapability( - access.AuthorizeAbility, - id.DID(), - access.AuthorizeCaveats{ - Iss: &iss, - Att: []access.CapabilityRequest{{Can: "*"}}, - }, - ) - - agent, err := identity.New("") - require.NoError(t, err) - - inv, err := invocation.Invoke(agent.Signer, id.Signer, cap) - require.NoError(t, err) - - _, _, err = handler(context.Background(), cap, inv, nil) - require.NoError(t, err) - - require.Contains(t, m.lastURL.String(), "http://myhost:9090/validate-email") - }) -} diff --git a/pkg/service/handlers/access_claim.go b/pkg/service/handlers/access_claim.go index a7db70f..3ae327b 100644 --- a/pkg/service/handlers/access_claim.go +++ b/pkg/service/handlers/access_claim.go @@ -1,220 +1,70 @@ package handlers import ( - "context" "fmt" + "github.com/fil-forge/libforge/capabilities/access" + "github.com/fil-forge/ucantone/execution/bindexec" + "github.com/fil-forge/ucantone/ucan" + "github.com/fil-forge/ucantone/ucan/container" "github.com/ipfs/go-cid" - "github.com/storacha/go-libstoracha/capabilities/access" - ucancap "github.com/storacha/go-libstoracha/capabilities/ucan" - "github.com/storacha/go-ucanto/core/delegation" - "github.com/storacha/go-ucanto/core/invocation" - "github.com/storacha/go-ucanto/core/receipt/fx" - "github.com/storacha/go-ucanto/core/result" - "github.com/storacha/go-ucanto/core/result/failure" - "github.com/storacha/go-ucanto/did" - "github.com/storacha/go-ucanto/principal/absentee" - "github.com/storacha/go-ucanto/server" - "github.com/storacha/go-ucanto/ucan" - "github.com/storacha/go-ucanto/validator" "github.com/storacha/sprue/pkg/identity" - "github.com/storacha/sprue/pkg/internal/ipldutil" - "github.com/storacha/sprue/pkg/lib/didmailto" - "github.com/storacha/sprue/pkg/lib/errors" - "github.com/storacha/sprue/pkg/lib/ucans" - "github.com/storacha/sprue/pkg/store" delegation_store "github.com/storacha/sprue/pkg/store/delegation" "go.uber.org/zap" ) -const InvalidClaimAudienceErrorName = "InvalidClaimAudience" - -var ErrInvalidClaimAudience = errors.New(InvalidClaimAudienceErrorName, "invalid claim audience DID") - -// WithAccessClaimMethod registers the access/claim handler. -func WithAccessClaimMethod(id *identity.Identity, delegationStore delegation_store.Store, logger *zap.Logger) server.Option { - return server.WithServiceMethod( - access.ClaimAbility, - server.Provide( - access.Claim, - AccessClaimHandler(id, delegationStore, logger), - ), - ) -} - -func AccessClaimHandler(id *identity.Identity, delegationStore delegation_store.Store, logger *zap.Logger) server.HandlerFunc[access.ClaimCaveats, access.ClaimOk, failure.IPLDBuilderFailure] { - log := logger.With(zap.String("handler", access.ClaimAbility)) - return func(ctx context.Context, - cap ucan.Capability[access.ClaimCaveats], - inv invocation.Invocation, - iCtx server.InvocationContext, - ) (result.Result[access.ClaimOk, failure.IPLDBuilderFailure], fx.Effects, error) { - agent := inv.Issuer().DID() - audience, err := did.Parse(cap.With()) - if err != nil { - log.Warn("invalid audience", zap.String("audience", cap.With())) - return result.Error[access.ClaimOk, failure.IPLDBuilderFailure](ErrInvalidClaimAudience), nil, nil - } - - log := log.With( - zap.Stringer("agent", agent), - zap.Stringer("audience", audience), - ) - log.Debug("claiming delegations") - - dlgs := map[cid.Cid]delegation.Delegation{} - var cursor *string - for { - var opts []delegation_store.ListByAudienceOption - if cursor != nil { - opts = append(opts, delegation_store.WithListByAudienceCursor(*cursor)) - } - page, err := delegationStore.ListByAudience(ctx, audience, opts...) - if err != nil { - return nil, nil, fmt.Errorf("listing delegations: %w", err) - } - for _, dlg := range page.Results { - root, err := ipldutil.ToCID(dlg.Link()) - if err != nil { - log.Warn("invalid delegation CID", zap.String("root", dlg.Link().String()), zap.Error(err)) - continue - } - dlgs[root] = dlg - } - if page.Cursor == nil { - break - } - cursor = page.Cursor - } - - refreshedDlgs := map[cid.Cid]delegation.Delegation{} - // Find any attested ucan:* delegations and replace them with fresh ones. - for root, dlg := range dlgs { - if len(dlg.Capabilities()) == 0 { - log.Warn("delegation with no capabilities", zap.String("root", dlg.Link().String())) - refreshedDlgs[root] = dlg - continue - } - - // Ignore delegations that aren't attestations, and ours. - cap := dlg.Capabilities()[0] - - var err error - match, err := ucancap.Attest.Match(validator.NewSource(cap, dlg)) - if err != nil { - refreshedDlgs[root] = dlg - continue - } - if match.Value().With() != id.DID() { - refreshedDlgs[root] = dlg - continue - } - - // Ignore invalid attestations. - if valid, _ := ucan.VerifySignature(dlg.Data(), id.Signer.Verifier()); !valid { - refreshedDlgs[root] = dlg - continue - } - if ucan.IsTooEarly(dlg) || ucan.IsExpired(dlg) { - refreshedDlgs[root] = dlg - continue - } - - attestedDlgRoot, err := ipldutil.ToCID(match.Value().Nb().Proof) - if err != nil { - log.Warn("invalid proof CID in attestation", zap.String("proof", match.Value().Nb().Proof.String()), zap.Error(err)) - refreshedDlgs[root] = dlg - continue - } - - // Ignore attestations of delegations we don't have. - attestedDlg, ok := dlgs[attestedDlgRoot] - if !ok { - refreshedDlgs[root] = dlg - continue - } - - // Create new session proofs for the attested delegation. - sessionPrfs, err := createSessionProofsForLogin( - ctx, - id.Signer, - delegationStore, - attestedDlg, +func NewAccessClaimHandler(id *identity.Identity, delegationStore delegation_store.Store, logger *zap.Logger) Handler { + log := logger.With(zap.String("handler", access.ClaimCommand)) + return Handler{ + Capability: access.Claim, + Handler: bindexec.NewHandler(func( + req *bindexec.Request[*access.ClaimArguments], + res *bindexec.Response[*access.ClaimOK], + ) error { + agent := req.Invocation().Issuer().DID() + audience := req.Invocation().Subject().DID() + + log := log.With( + zap.Stringer("agent", agent), + zap.Stringer("audience", audience), ) - if err != nil { - log.Error("failed to create session proofs for login", zap.Error(err)) - return nil, nil, fmt.Errorf("creating session proofs for login: %w", err) - } - for _, d := range sessionPrfs { - root, err := ipldutil.ToCID(d.Link()) + log.Debug("claiming delegations") + + links := []cid.Cid{} + delegations := []ucan.Delegation{} + attestations := []ucan.Invocation{} + var cursor *string + for { + var opts []delegation_store.ListByAudienceOption + if cursor != nil { + opts = append(opts, delegation_store.WithListByAudienceCursor(*cursor)) + } + page, err := delegationStore.ListByAudience(req.Context(), audience, opts...) if err != nil { - return nil, nil, fmt.Errorf("getting CID of session proof delegation: %w", err) + return fmt.Errorf("listing delegations: %w", err) } - refreshedDlgs[root] = d - } - } - - mdl := access.DelegationsModel{Values: map[string][]byte{}} - for _, d := range dlgs { - k := d.Link().String() - v, err := ucans.ArchiveDelegations(d) - if err != nil { - log.Error("failed to archive delegation", zap.Error(err)) - return nil, nil, fmt.Errorf("archiving delegation: %w", err) + for _, token := range page.Results { + if dlg, ok := token.(ucan.Delegation); ok { + delegations = append(delegations, dlg) + links = append(links, dlg.Link()) + } else if inv, ok := token.(ucan.Invocation); ok { + attestations = append(attestations, inv) + } else { + log.Warn("unexpected token type in delegation store", zap.Stringer("link", token.Link())) + } + } + if page.Cursor == nil { + break + } + cursor = page.Cursor } - mdl.Keys = append(mdl.Keys, k) - mdl.Values[k] = v - } - - return result.Ok[access.ClaimOk, failure.IPLDBuilderFailure](access.ClaimOk{ - Delegations: mdl, - }), nil, nil - } -} -func createSessionProofsForLogin( - ctx context.Context, - service ucan.Signer, - delegationStore delegation_store.Store, - loginDlg delegation.Delegation, -) ([]delegation.Delegation, error) { - // These should always be accounts (did:mailto:), but if one's not, skip it. - account, err := didmailto.Parse(loginDlg.Issuer().DID().String()) - if err != nil { - return []delegation.Delegation{}, nil - } - - accountDlgs, err := store.Collect(ctx, func(ctx context.Context, options store.PaginationConfig) (store.Page[delegation.Delegation], error) { - var opts []delegation_store.ListByAudienceOption - if options.Cursor != nil { - opts = append(opts, delegation_store.WithListByAudienceCursor(*options.Cursor)) - } - return delegationStore.ListByAudience(ctx, account, opts...) - }) - if err != nil { - return nil, fmt.Errorf("collecting delegations for account: %w", err) - } - - caps := make([]ucan.Capability[ucan.NoCaveats], 0, len(loginDlg.Capabilities())) - for _, cap := range loginDlg.Capabilities() { - caps = append(caps, ucan.NewCapability(cap.Can(), cap.With(), ucan.NoCaveats{})) - } + res.SetMetadata(container.New( + container.WithDelegations(delegations...), + container.WithInvocations(attestations...), + )) - dlg, attestation, err := createSessionProofs( - service, - absentee.From(account), - loginDlg.Audience(), - toFactBuilders(loginDlg.Facts()), - caps, - // We include all the delegations to the account so that the agent will - // have delegation chains to all the delegated resources. - // We should actually filter out only delegations that support delegated - // capabilities, but for now we just include all of them since we only - // implement sudo access anyway. - accountDlgs, - ) - if err != nil { - return nil, fmt.Errorf("creating session proofs: %w", err) + return res.SetSuccess(&access.ClaimOK{Delegations: links}) + }), } - return []delegation.Delegation{dlg, attestation}, nil } diff --git a/pkg/service/handlers/access_claim_test.go b/pkg/service/handlers/access_claim_test.go index 6b1d629..b815b29 100644 --- a/pkg/service/handlers/access_claim_test.go +++ b/pkg/service/handlers/access_claim_test.go @@ -1,16 +1,17 @@ package handlers import ( - "context" "testing" - "github.com/storacha/go-libstoracha/capabilities/access" - "github.com/storacha/go-ucanto/core/delegation" - "github.com/storacha/go-ucanto/core/invocation" - "github.com/storacha/go-ucanto/core/result" - "github.com/storacha/go-ucanto/ucan" + "github.com/fil-forge/libforge/capabilities/access" + "github.com/fil-forge/ucantone/execution" + "github.com/fil-forge/ucantone/ipld/datamodel" + "github.com/fil-forge/ucantone/result" + "github.com/fil-forge/ucantone/ucan" + "github.com/fil-forge/ucantone/ucan/delegation" + "github.com/fil-forge/ucantone/ucan/invocation" + "github.com/ipfs/go-cid" "github.com/storacha/sprue/internal/testutil" - "github.com/storacha/sprue/pkg/identity" dlgmemory "github.com/storacha/sprue/pkg/store/delegation/memory" "github.com/stretchr/testify/require" "go.uber.org/zap/zaptest" @@ -19,183 +20,156 @@ import ( func TestAccessClaimHandler(t *testing.T) { logger := zaptest.NewLogger(t) - t.Run("invalid audience DID", func(t *testing.T) { - id := newTestIdentity(t) - store := dlgmemory.New() - handler := AccessClaimHandler(id, store, logger) - - cap := ucan.NewCapability( - access.ClaimAbility, - "not-a-did", - access.ClaimCaveats{}, - ) - - agent, err := identity.New("") - require.NoError(t, err) - - inv, err := invocation.Invoke(agent.Signer, id.Signer, cap) - require.NoError(t, err) - - res, _, err := handler(context.Background(), cap, inv, nil) - require.NoError(t, err) - - _, fail := result.Unwrap(res) - require.NotNil(t, fail) - }) - t.Run("no delegations", func(t *testing.T) { id := newTestIdentity(t) store := dlgmemory.New() - handler := AccessClaimHandler(id, store, logger) + handler := NewAccessClaimHandler(id, store, logger) - agent, err := identity.New("") - require.NoError(t, err) + agent := testutil.RandomSigner(t) - cap := ucan.NewCapability( - access.ClaimAbility, - agent.DID(), - access.ClaimCaveats{}, + args := access.ClaimArguments{} + inv, err := access.Claim.Invoke( + agent, + agent, + &args, + invocation.WithAudience(id.Signer), ) + require.NoError(t, err) - inv, err := invocation.Invoke(agent.Signer, id.Signer, cap) + req := execution.NewRequest(t.Context(), inv) + res, err := execution.NewResponse(req.Invocation().Task().Link(), execution.WithSigner(id.Signer)) require.NoError(t, err) - res, _, err := handler(context.Background(), cap, inv, nil) + err = handler.Handler(req, res) require.NoError(t, err) - ok, fail := result.Unwrap(res) + o, fail := result.Unwrap(res.Receipt().Out()) require.Nil(t, fail) - require.Empty(t, ok.Delegations.Keys) + require.NotNil(t, o) + + ok := access.ClaimOK{} + err = datamodel.Rebind(datamodel.NewAny(o), &ok) + require.NoError(t, err) + require.Empty(t, ok.Delegations) }) t.Run("returns stored delegations", func(t *testing.T) { id := newTestIdentity(t) store := dlgmemory.New() - handler := AccessClaimHandler(id, store, logger) + handler := NewAccessClaimHandler(id, store, logger) - agent, err := identity.New("") - require.NoError(t, err) + agent := testutil.RandomSigner(t) - // Create a delegation to the agent - dlg, err := delegation.Delegate( - testutil.Alice, - agent.Signer, - []ucan.Capability[ucan.NoCaveats]{ - ucan.NewCapability("test/thing", testutil.Alice.DID().String(), ucan.NoCaveats{}), - }, - ) + dlg, err := delegation.Delegate(testutil.Alice, agent, testutil.Alice, "/test/thing") require.NoError(t, err) - cause := testutil.RandomCID(t) - err = store.PutMany(context.Background(), []delegation.Delegation{dlg}, cause) + err = store.PutMany(t.Context(), []ucan.Token{dlg}, testutil.RandomCID(t)) require.NoError(t, err) - cap := ucan.NewCapability( - access.ClaimAbility, - agent.DID(), - access.ClaimCaveats{}, + args := access.ClaimArguments{} + inv, err := access.Claim.Invoke( + agent, + agent, + &args, + invocation.WithAudience(id.Signer), ) + require.NoError(t, err) - inv, err := invocation.Invoke(agent.Signer, id.Signer, cap) + req := execution.NewRequest(t.Context(), inv) + res, err := execution.NewResponse(req.Invocation().Task().Link(), execution.WithSigner(id.Signer)) require.NoError(t, err) - res, _, err := handler(context.Background(), cap, inv, nil) + err = handler.Handler(req, res) require.NoError(t, err) - ok, fail := result.Unwrap(res) + o, fail := result.Unwrap(res.Receipt().Out()) require.Nil(t, fail) - require.Len(t, ok.Delegations.Keys, 1) - require.Equal(t, dlg.Link().String(), ok.Delegations.Keys[0]) - require.NotEmpty(t, ok.Delegations.Values[ok.Delegations.Keys[0]]) + + ok := access.ClaimOK{} + err = datamodel.Rebind(datamodel.NewAny(o), &ok) + require.NoError(t, err) + require.Equal(t, []cid.Cid{dlg.Link()}, ok.Delegations) }) t.Run("returns multiple delegations", func(t *testing.T) { id := newTestIdentity(t) store := dlgmemory.New() - handler := AccessClaimHandler(id, store, logger) + handler := NewAccessClaimHandler(id, store, logger) - agent, err := identity.New("") - require.NoError(t, err) + agent := testutil.RandomSigner(t) - dlg1, err := delegation.Delegate( - testutil.Alice, - agent.Signer, - []ucan.Capability[ucan.NoCaveats]{ - ucan.NewCapability("test/one", testutil.Alice.DID().String(), ucan.NoCaveats{}), - }, - ) + dlg1, err := delegation.Delegate(testutil.Alice, agent, testutil.Alice, "/test/one") require.NoError(t, err) - dlg2, err := delegation.Delegate( - testutil.Bob, - agent.Signer, - []ucan.Capability[ucan.NoCaveats]{ - ucan.NewCapability("test/two", testutil.Bob.DID().String(), ucan.NoCaveats{}), - }, - ) + dlg2, err := delegation.Delegate(testutil.Bob, agent, testutil.Bob, "/test/two") require.NoError(t, err) - cause := testutil.RandomCID(t) - err = store.PutMany(context.Background(), []delegation.Delegation{dlg1, dlg2}, cause) + err = store.PutMany(t.Context(), []ucan.Token{dlg1, dlg2}, testutil.RandomCID(t)) require.NoError(t, err) - cap := ucan.NewCapability( - access.ClaimAbility, - agent.DID(), - access.ClaimCaveats{}, + args := access.ClaimArguments{} + inv, err := access.Claim.Invoke( + agent, + agent, + &args, + invocation.WithAudience(id.Signer), ) + require.NoError(t, err) - inv, err := invocation.Invoke(agent.Signer, id.Signer, cap) + req := execution.NewRequest(t.Context(), inv) + res, err := execution.NewResponse(req.Invocation().Task().Link(), execution.WithSigner(id.Signer)) require.NoError(t, err) - res, _, err := handler(context.Background(), cap, inv, nil) + err = handler.Handler(req, res) require.NoError(t, err) - ok, fail := result.Unwrap(res) + o, fail := result.Unwrap(res.Receipt().Out()) require.Nil(t, fail) - require.Len(t, ok.Delegations.Keys, 2) + + ok := access.ClaimOK{} + err = datamodel.Rebind(datamodel.NewAny(o), &ok) + require.NoError(t, err) + require.Len(t, ok.Delegations, 2) + require.ElementsMatch(t, []cid.Cid{dlg1.Link(), dlg2.Link()}, ok.Delegations) }) t.Run("does not return delegations for other audiences", func(t *testing.T) { id := newTestIdentity(t) store := dlgmemory.New() - handler := AccessClaimHandler(id, store, logger) - - agent, err := identity.New("") - require.NoError(t, err) + handler := NewAccessClaimHandler(id, store, logger) - otherAgent, err := identity.New("") - require.NoError(t, err) + agent := testutil.RandomSigner(t) + otherAgent := testutil.RandomSigner(t) - // Delegate to a different agent - dlg, err := delegation.Delegate( - testutil.Alice, - otherAgent.Signer, - []ucan.Capability[ucan.NoCaveats]{ - ucan.NewCapability("test/thing", testutil.Alice.DID().String(), ucan.NoCaveats{}), - }, - ) + // Delegation is for otherAgent, not agent. + dlg, err := delegation.Delegate(testutil.Alice, otherAgent, testutil.Alice, "/test/thing") require.NoError(t, err) - cause := testutil.RandomCID(t) - err = store.PutMany(context.Background(), []delegation.Delegation{dlg}, cause) + err = store.PutMany(t.Context(), []ucan.Token{dlg}, testutil.RandomCID(t)) require.NoError(t, err) - // Claim as the original agent — should get nothing - cap := ucan.NewCapability( - access.ClaimAbility, - agent.DID(), - access.ClaimCaveats{}, + args := access.ClaimArguments{} + inv, err := access.Claim.Invoke( + agent, + agent, + &args, + invocation.WithAudience(id.Signer), ) + require.NoError(t, err) - inv, err := invocation.Invoke(agent.Signer, id.Signer, cap) + req := execution.NewRequest(t.Context(), inv) + res, err := execution.NewResponse(req.Invocation().Task().Link(), execution.WithSigner(id.Signer)) require.NoError(t, err) - res, _, err := handler(context.Background(), cap, inv, nil) + err = handler.Handler(req, res) require.NoError(t, err) - ok, fail := result.Unwrap(res) + o, fail := result.Unwrap(res.Receipt().Out()) require.Nil(t, fail) - require.Empty(t, ok.Delegations.Keys) + + ok := access.ClaimOK{} + err = datamodel.Rebind(datamodel.NewAny(o), &ok) + require.NoError(t, err) + require.Empty(t, ok.Delegations) }) } diff --git a/pkg/service/handlers/access_confirm.go b/pkg/service/handlers/access_confirm.go index 53f58fe..b2dce39 100644 --- a/pkg/service/handlers/access_confirm.go +++ b/pkg/service/handlers/access_confirm.go @@ -1,192 +1,158 @@ package handlers import ( - "context" "fmt" - "go.uber.org/zap" - + "github.com/fil-forge/libforge/capabilities/access" + "github.com/fil-forge/libforge/capabilities/ucan/attest" + "github.com/fil-forge/libforge/didmailto" + "github.com/fil-forge/ucantone/errors" + "github.com/fil-forge/ucantone/execution/bindexec" + "github.com/fil-forge/ucantone/ipld" + "github.com/fil-forge/ucantone/ipld/datamodel" + "github.com/fil-forge/ucantone/principal/absentee" + "github.com/fil-forge/ucantone/ucan" + "github.com/fil-forge/ucantone/ucan/container" + "github.com/fil-forge/ucantone/ucan/delegation" + "github.com/fil-forge/ucantone/ucan/invocation" "github.com/ipfs/go-cid" - "github.com/ipld/go-ipld-prime/node/basicnode" - "github.com/storacha/go-libstoracha/capabilities/access" - ucan_caps "github.com/storacha/go-libstoracha/capabilities/ucan" - "github.com/storacha/go-ucanto/core/delegation" - "github.com/storacha/go-ucanto/core/invocation" - "github.com/storacha/go-ucanto/core/ipld" - "github.com/storacha/go-ucanto/core/receipt/fx" - "github.com/storacha/go-ucanto/core/result" - "github.com/storacha/go-ucanto/core/result/failure" - "github.com/storacha/go-ucanto/principal/absentee" - "github.com/storacha/go-ucanto/principal/ed25519/verifier" - "github.com/storacha/go-ucanto/server" - "github.com/storacha/go-ucanto/ucan" "github.com/storacha/sprue/pkg/identity" - "github.com/storacha/sprue/pkg/lib/errors" - "github.com/storacha/sprue/pkg/lib/ucans" delegation_store "github.com/storacha/sprue/pkg/store/delegation" + "go.uber.org/zap" ) -const InvalidAccessConfirmDelegationErrorName = "InvalidAccessConfirmDelegation" - -// WithAccessConfirmMethod registers the access/confirm handler. -func WithAccessConfirmMethod(id *identity.Identity, delegationStore delegation_store.Store, logger *zap.Logger) server.Option { - return server.WithServiceMethod( - access.ConfirmAbility, - server.Provide( - access.Confirm, - AccessConfirmHandler(id, delegationStore, logger), - ), - ) -} - -func AccessConfirmHandler(id *identity.Identity, delegationStore delegation_store.Store, logger *zap.Logger) server.HandlerFunc[access.ConfirmCaveats, access.ConfirmOk, failure.IPLDBuilderFailure] { - log := logger.With(zap.String("handler", access.ConfirmAbility)) - return func(ctx context.Context, - cap ucan.Capability[access.ConfirmCaveats], - inv invocation.Invocation, - iCtx server.InvocationContext, - ) (result.Result[access.ConfirmOk, failure.IPLDBuilderFailure], fx.Effects, error) { - if cap.With() != id.DID() { - log.Warn("not a valid delegation", zap.String("resource", cap.With())) - return result.Error[access.ConfirmOk, failure.IPLDBuilderFailure]( - errors.New(InvalidAccessConfirmDelegationErrorName, "not a valid access/confirm delegation"), - ), nil, nil - } +const InvalidAccessConfirmInvocationErrorName = "InvalidAccessConfirmInvocation" + +func NewAccessConfirmHandler(id *identity.Identity, delegationStore delegation_store.Store, logger *zap.Logger) Handler { + log := logger.With(zap.String("handler", access.ConfirmCommand)) + return Handler{ + Capability: access.Confirm, + Handler: bindexec.NewHandler(func( + req *bindexec.Request[*access.ConfirmArguments], + res *bindexec.Response[*access.ConfirmOK], + ) error { + args := req.Task().BindArguments() + if req.Invocation().Subject().DID() != id.Signer.DID() { + log.Warn("not a valid invocation", zap.Stringer("subject", req.Invocation().Subject().DID())) + return res.SetFailure(errors.New(InvalidAccessConfirmInvocationErrorName, "not a valid access/confirm delegation")) + } - // Create a absentee signer for the account that authorized the delegation - account := absentee.From(cap.Nb().Iss) - agent, err := verifier.Parse(cap.Nb().Aud.String()) - if err != nil { - log.Warn("invalid audience", zap.Stringer("audience", cap.Nb().Aud)) - return result.Error[access.ConfirmOk, failure.IPLDBuilderFailure]( - errors.New(InvalidAccessConfirmDelegationErrorName, "invalid agent DID in delegation"), - ), nil, nil - } + accountDID, err := didmailto.Parse(args.Issuer.DID().String()) + if err != nil { + log.Warn("invalid issuer DID", zap.Stringer("issuer", args.Issuer.DID()), zap.Error(err)) + return res.SetFailure(errors.New(InvalidAccessConfirmInvocationErrorName, "invalid issuer DID in delegation")) + } - abilities := []ucan.Ability{} - for _, att := range cap.Nb().Att { - abilities = append(abilities, att.Can) - } + // Create a absentee signer for the account that authorized the delegation + account := absentee.From(accountDID) + agent := args.Audience - log := log.With( - zap.Stringer("agent", agent.DID()), - zap.Stringer("account", account.DID()), - ) - log.Debug("confirming access", zap.Strings("abilities", abilities)) - - // In the future we should instead render a page and allow a user to select - // which delegations they wish to re-delegate. Right now we just re-delegate - // everything that was requested for all of the resources. - var capabilities []ucan.Capability[ucan.NoCaveats] - for _, att := range cap.Nb().Att { - capabilities = append(capabilities, ucan.NewCapability(att.Can, "ucan:*", ucan.NoCaveats{})) - } + cmds := make([]string, 0, len(args.Attenuations)) + for _, att := range args.Attenuations { + cmds = append(cmds, att.Command.String()) + } - // Create session proofs, but containing no Space proofs. We'll store these, - // and generate the Space proofs on access/claim. - dlg, attestation, err := createSessionProofs( - id.Signer, - account, - agent, - []ucan.FactBuilder{ - accessConfirmFact{ - accessRequest: cap.Nb().Cause, - accessConfirm: inv.Link(), + log := log.With( + zap.Stringer("agent", agent.DID()), + zap.Stringer("account", account.DID()), + zap.Stringer("cause", args.Cause), + zap.Strings("commands", cmds), + ) + log.Debug("confirming access") + + // Create session proofs, but containing no Space proofs. We'll store these, + // and generate the Space proofs on access/claim. + delegations, attestations, err := createSessionProofs( + id.Signer, + account, + agent, + args.Attenuations, + datamodel.Map{ + access.RequestMetaKey: args.Cause, + access.ConfirmMetaKey: req.Invocation().Task().Link(), }, - }, - capabilities, - []delegation.Delegation{}, - ) - if err != nil { - return nil, nil, fmt.Errorf("creating session proofs: %w", err) - } - - dlgs := []delegation.Delegation{dlg, attestation} + ) + if err != nil { + return fmt.Errorf("creating session proofs: %w", err) + } - // Store the delegations so that they can be pulled during access/claim. - // Since there is no invocation that contains these delegations, don't pass - // a `cause` parameter. - // TODO: we should invoke access/delegate here rather than interacting with - // the delegations storage system directly. - err = delegationStore.PutMany(ctx, dlgs, cid.Undef) - if err != nil { - log.Error("failed to store delegations", zap.Error(err)) - return nil, nil, fmt.Errorf("storing delegations: %w", err) - } + links := make([]cid.Cid, 0, len(delegations)) + tokens := make([]ucan.Token, 0, len(delegations)+len(attestations)) + for _, d := range delegations { + tokens = append(tokens, d) + links = append(links, d.Link()) + } + for _, a := range attestations { + tokens = append(tokens, a) + } - mdl := access.DelegationsModel{Values: map[string][]byte{}} - for _, d := range dlgs { - k := d.Link().String() - v, err := ucans.ArchiveDelegations(d) + // Store the delegations so that they can be pulled during /access/claim. + // Since there is no invocation that contains these delegations, don't pass + // a `cause` parameter. + // TODO: we should invoke /access/delegate here rather than interacting + // with the delegations storage system directly. + err = delegationStore.PutMany(req.Context(), tokens, cid.Undef) if err != nil { - log.Error("failed to archive delegation", zap.Error(err)) - return nil, nil, fmt.Errorf("archiving delegation: %w", err) + log.Error("failed to store delegations", zap.Error(err)) + return fmt.Errorf("storing delegations: %w", err) } - mdl.Keys = append(mdl.Keys, k) - mdl.Values[k] = v - } - return result.Ok[access.ConfirmOk, failure.IPLDBuilderFailure](access.ConfirmOk{ - Delegations: mdl, - }), nil, nil - } -} - -type accessConfirmFact struct { - accessRequest ipld.Link - accessConfirm ipld.Link -} + // Include the delegations and attestations in the response metadata. + res.SetMetadata(container.New( + container.WithDelegations(delegations...), + container.WithInvocations(attestations...), + )) -func (f accessConfirmFact) ToIPLD() (map[string]ipld.Node, error) { - return map[string]ipld.Node{ - "access/request": basicnode.NewLink(f.accessRequest), - "access/confirm": basicnode.NewLink(f.accessConfirm), - }, nil + return res.SetSuccess(&access.ConfirmOK{Delegations: links}) + }), + } } -// createSessionProofs creates a delegation from the account to the agent, and -// an attestation from the service to the agent referencing that delegation. -func createSessionProofs[C ucan.CaveatBuilder]( +// createSessionProofs creates delegations from the account to the agent, and +// attestations from the service to the agent referencing those delegations. +func createSessionProofs( service ucan.Signer, - account ucan.Signer, + account absentee.Signer, agent ucan.Principal, - facts []ucan.FactBuilder, - capabilities []ucan.Capability[C], - delegationProofs []delegation.Delegation, -) (delegation.Delegation, delegation.Delegation, error) { - var proofs []delegation.Proof - for _, d := range delegationProofs { - proofs = append(proofs, delegation.FromDelegation(d)) - } - - // create an delegation on behalf of the account with an absent signature. - dlg, err := delegation.Delegate( - account, - agent, - capabilities, - delegation.WithProof(proofs...), - delegation.WithFacts(facts), - // default to Infinity is reasonable here because - // account consented to this. - delegation.WithNoExpiration(), - ) - if err != nil { - return nil, nil, fmt.Errorf("creating delegation: %w", err) - } + attenuations []access.CapabilityRequest, + meta ipld.Map, +) ([]ucan.Delegation, []ucan.Invocation, error) { + delegations := make([]ucan.Delegation, 0, len(attenuations)) + attestations := make([]ucan.Invocation, 0, len(attenuations)) + + for _, req := range attenuations { + dlg, err := delegation.Delegate( + account, + agent, + // TODO: optionally set subject in capability request + // no subject (powerline) will apply to all spaces present and future + nil, + req.Command, + delegation.WithMetadata(meta), + // default to Infinity is reasonable here because + // account consented to this. + delegation.WithNoExpiration(), + ) + if err != nil { + return nil, nil, fmt.Errorf("creating delegation: %w", err) + } + delegations = append(delegations, dlg) - attestation, err := ucan_caps.Attest.Delegate( - service, - agent, - service.DID().String(), - ucan_caps.AttestCaveats{ - Proof: dlg.Link(), - }, - delegation.WithFacts(facts), - delegation.WithNoExpiration(), - ) - if err != nil { - return nil, nil, fmt.Errorf("creating attestation: %w", err) + attestation, err := attest.Proof.Invoke( + service, + service, + &attest.ProofArguments{ + Proof: dlg.Link(), + }, + invocation.WithAudience(agent), + invocation.WithMetadata(meta), + invocation.WithNoExpiration(), + ) + if err != nil { + return nil, nil, fmt.Errorf("creating attestation: %w", err) + } + attestations = append(attestations, attestation) } - return dlg, attestation, nil + return delegations, attestations, nil } diff --git a/pkg/service/handlers/access_confirm_test.go b/pkg/service/handlers/access_confirm_test.go index 7ddb08d..70a4efe 100644 --- a/pkg/service/handlers/access_confirm_test.go +++ b/pkg/service/handlers/access_confirm_test.go @@ -1,17 +1,17 @@ package handlers import ( - "context" "testing" - "github.com/storacha/go-libstoracha/capabilities/access" - "github.com/storacha/go-ucanto/core/invocation" - "github.com/storacha/go-ucanto/core/result" - "github.com/storacha/go-ucanto/did" - "github.com/storacha/go-ucanto/ucan" + "github.com/fil-forge/libforge/capabilities/access" + adm "github.com/fil-forge/libforge/capabilities/access/datamodel" + "github.com/fil-forge/libforge/didmailto" + edm "github.com/fil-forge/ucantone/errors/datamodel" + "github.com/fil-forge/ucantone/execution" + "github.com/fil-forge/ucantone/ipld/datamodel" + "github.com/fil-forge/ucantone/result" + "github.com/fil-forge/ucantone/ucan/invocation" "github.com/storacha/sprue/internal/testutil" - "github.com/storacha/sprue/pkg/identity" - "github.com/storacha/sprue/pkg/lib/didmailto" dlgmemory "github.com/storacha/sprue/pkg/store/delegation/memory" "github.com/stretchr/testify/require" "go.uber.org/zap/zaptest" @@ -20,115 +20,135 @@ import ( func TestAccessConfirmHandler(t *testing.T) { logger := zaptest.NewLogger(t) - t.Run("wrong resource DID", func(t *testing.T) { + t.Run("wrong subject", func(t *testing.T) { id := newTestIdentity(t) store := dlgmemory.New() - handler := AccessConfirmHandler(id, store, logger) - - agent, err := identity.New("") - require.NoError(t, err) - - account := mustMailtoDID(t, "alice@example.com") - causeCID := testutil.RandomCID(t) - - cap := ucan.NewCapability( - access.ConfirmAbility, - "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK", - access.ConfirmCaveats{ - Iss: account, - Aud: agent.Signer.DID(), - Att: []access.CapabilityRequest{{Can: "*"}}, - Cause: testutil.Must(invocation.Invoke(agent.Signer, id.Signer, ucan.NewCapability("test/thing", id.DID(), ucan.NoCaveats{})))(t).Link(), + handler := NewAccessConfirmHandler(id, store, logger) + + account := testutil.Must(didmailto.New("alice@example.com"))(t) + agent := testutil.RandomSigner(t) + notService := testutil.RandomSigner(t) + + args := access.ConfirmArguments{ + Cause: testutil.RandomCID(t), + Issuer: account, + Audience: agent.DID(), + Attenuations: []adm.CapabilityRequestModel{ + {Command: "/"}, }, + } + + // Subject is not id.Signer — handler should reject. + inv, err := access.Confirm.Invoke( + id.Signer, + notService, + &args, + invocation.WithAudience(id.Signer), ) - _ = causeCID + require.NoError(t, err) - inv, err := invocation.Invoke(id.Signer, id.Signer, cap) + req := execution.NewRequest(t.Context(), inv) + res, err := execution.NewResponse(req.Invocation().Task().Link(), execution.WithSigner(id.Signer)) require.NoError(t, err) - res, _, err := handler(context.Background(), cap, inv, nil) + err = handler.Handler(req, res) require.NoError(t, err) - _, fail := result.Unwrap(res) + _, fail := result.Unwrap(res.Receipt().Out()) require.NotNil(t, fail) + + model := edm.ErrorModel{} + err = datamodel.Rebind(datamodel.NewAny(fail), &model) + require.NoError(t, err) + require.Equal(t, InvalidAccessConfirmInvocationErrorName, model.Name()) }) - t.Run("invalid agent DID", func(t *testing.T) { + t.Run("invalid issuer DID", func(t *testing.T) { id := newTestIdentity(t) store := dlgmemory.New() - handler := AccessConfirmHandler(id, store, logger) + handler := NewAccessConfirmHandler(id, store, logger) + + // A did:key (not a did:mailto) — didmailto.Parse will reject it. + nonMailto := testutil.RandomSigner(t) + agent := testutil.RandomSigner(t) + + args := access.ConfirmArguments{ + Cause: testutil.RandomCID(t), + Issuer: nonMailto.DID(), + Audience: agent.DID(), + Attenuations: []adm.CapabilityRequestModel{ + {Command: "/"}, + }, + } - account := mustMailtoDID(t, "alice@example.com") - badAud, err := did.Parse("did:mailto:example.com:alice") + inv, err := access.Confirm.Invoke( + id.Signer, + id.Signer, + &args, + invocation.WithAudience(id.Signer), + ) require.NoError(t, err) - agent, err := identity.New("") + req := execution.NewRequest(t.Context(), inv) + res, err := execution.NewResponse(req.Invocation().Task().Link(), execution.WithSigner(id.Signer)) require.NoError(t, err) - causeInv, err := invocation.Invoke(agent.Signer, id.Signer, ucan.NewCapability("test/thing", id.DID(), ucan.NoCaveats{})) + err = handler.Handler(req, res) require.NoError(t, err) - cap := ucan.NewCapability( - access.ConfirmAbility, - id.DID(), - access.ConfirmCaveats{ - Iss: account, - Aud: badAud, - Att: []access.CapabilityRequest{{Can: "*"}}, - Cause: causeInv.Link(), - }, - ) - - inv, err := invocation.Invoke(id.Signer, id.Signer, cap) - require.NoError(t, err) + _, fail := result.Unwrap(res.Receipt().Out()) + require.NotNil(t, fail) - res, _, err := handler(context.Background(), cap, inv, nil) + model := edm.ErrorModel{} + err = datamodel.Rebind(datamodel.NewAny(fail), &model) require.NoError(t, err) - - _, fail := result.Unwrap(res) - require.NotNil(t, fail) + require.Equal(t, InvalidAccessConfirmInvocationErrorName, model.Name()) }) t.Run("success", func(t *testing.T) { id := newTestIdentity(t) store := dlgmemory.New() - handler := AccessConfirmHandler(id, store, logger) - - agent, err := identity.New("") - require.NoError(t, err) - - account := mustMailtoDID(t, "bob@example.com") + handler := NewAccessConfirmHandler(id, store, logger) - causeInv, err := invocation.Invoke(agent.Signer, id.Signer, ucan.NewCapability("test/thing", id.DID(), ucan.NoCaveats{})) - require.NoError(t, err) + account := testutil.Must(didmailto.New("bob@example.com"))(t) + agent := testutil.RandomSigner(t) - cap := ucan.NewCapability( - access.ConfirmAbility, - id.DID(), - access.ConfirmCaveats{ - Iss: account, - Aud: agent.Signer.DID(), - Att: []access.CapabilityRequest{{Can: "*"}}, - Cause: causeInv.Link(), + args := access.ConfirmArguments{ + Cause: testutil.RandomCID(t), + Issuer: account, + Audience: agent.DID(), + Attenuations: []adm.CapabilityRequestModel{ + {Command: "/"}, }, + } + + inv, err := access.Confirm.Invoke( + id.Signer, + id.Signer, + &args, + invocation.WithAudience(id.Signer), ) + require.NoError(t, err) - inv, err := invocation.Invoke(id.Signer, id.Signer, cap) + req := execution.NewRequest(t.Context(), inv) + res, err := execution.NewResponse(req.Invocation().Task().Link(), execution.WithSigner(id.Signer)) require.NoError(t, err) - res, _, err := handler(context.Background(), cap, inv, nil) + err = handler.Handler(req, res) require.NoError(t, err) - ok, fail := result.Unwrap(res) + o, fail := result.Unwrap(res.Receipt().Out()) require.Nil(t, fail) - // Should return 2 delegations: the account->agent delegation and the service attestation - require.Len(t, ok.Delegations.Keys, 2) - for _, k := range ok.Delegations.Keys { - require.NotEmpty(t, ok.Delegations.Values[k]) - } + require.NotNil(t, o) - // Verify delegations were stored - page, err := store.ListByAudience(context.Background(), agent.Signer.DID()) + ok := access.ConfirmOK{} + err = datamodel.Rebind(datamodel.NewAny(o), &ok) + require.NoError(t, err) + // One delegation link per attenuation. + require.Len(t, ok.Delegations, 1) + + // Store holds the delegation and its attestation, both keyed by the agent. + page, err := store.ListByAudience(t.Context(), agent.DID()) require.NoError(t, err) require.Len(t, page.Results, 2) }) @@ -136,45 +156,47 @@ func TestAccessConfirmHandler(t *testing.T) { t.Run("multiple capabilities", func(t *testing.T) { id := newTestIdentity(t) store := dlgmemory.New() - handler := AccessConfirmHandler(id, store, logger) + handler := NewAccessConfirmHandler(id, store, logger) + + account := testutil.Must(didmailto.New("carol@example.com"))(t) + agent := testutil.RandomSigner(t) + + args := access.ConfirmArguments{ + Cause: testutil.RandomCID(t), + Issuer: account, + Audience: agent.DID(), + Attenuations: []adm.CapabilityRequestModel{ + {Command: "/space/blob/add"}, + {Command: "/upload/add"}, + }, + } - agent, err := identity.New("") + inv, err := access.Confirm.Invoke( + id.Signer, + id.Signer, + &args, + invocation.WithAudience(id.Signer), + ) require.NoError(t, err) - account := mustMailtoDID(t, "carol@example.com") + req := execution.NewRequest(t.Context(), inv) + res, err := execution.NewResponse(req.Invocation().Task().Link(), execution.WithSigner(id.Signer)) + require.NoError(t, err) - causeInv, err := invocation.Invoke(agent.Signer, id.Signer, ucan.NewCapability("test/thing", id.DID(), ucan.NoCaveats{})) + err = handler.Handler(req, res) require.NoError(t, err) - cap := ucan.NewCapability( - access.ConfirmAbility, - id.DID(), - access.ConfirmCaveats{ - Iss: account, - Aud: agent.Signer.DID(), - Att: []access.CapabilityRequest{ - {Can: "space/blob/add"}, - {Can: "upload/add"}, - }, - Cause: causeInv.Link(), - }, - ) + o, fail := result.Unwrap(res.Receipt().Out()) + require.Nil(t, fail) - inv, err := invocation.Invoke(id.Signer, id.Signer, cap) + ok := access.ConfirmOK{} + err = datamodel.Rebind(datamodel.NewAny(o), &ok) require.NoError(t, err) + require.Len(t, ok.Delegations, 2) - res, _, err := handler(context.Background(), cap, inv, nil) + // Two attenuations → two delegations and two attestations stored. + page, err := store.ListByAudience(t.Context(), agent.DID()) require.NoError(t, err) - - ok, fail := result.Unwrap(res) - require.Nil(t, fail) - require.Len(t, ok.Delegations.Keys, 2) + require.Len(t, page.Results, 4) }) } - -func mustMailtoDID(t *testing.T, email string) did.DID { - t.Helper() - d, err := didmailto.New(email) - require.NoError(t, err) - return d -} diff --git a/pkg/service/handlers/access_delegate.go b/pkg/service/handlers/access_delegate.go index 9eb0f09..355e3f7 100644 --- a/pkg/service/handlers/access_delegate.go +++ b/pkg/service/handlers/access_delegate.go @@ -1,25 +1,16 @@ package handlers import ( - "context" "fmt" - "go.uber.org/zap" - - "github.com/storacha/go-libstoracha/capabilities/access" - "github.com/storacha/go-ucanto/core/dag/blockstore" - "github.com/storacha/go-ucanto/core/delegation" - "github.com/storacha/go-ucanto/core/invocation" - "github.com/storacha/go-ucanto/core/receipt/fx" - "github.com/storacha/go-ucanto/core/result" - "github.com/storacha/go-ucanto/core/result/failure" - "github.com/storacha/go-ucanto/did" - "github.com/storacha/go-ucanto/server" - "github.com/storacha/go-ucanto/ucan" - "github.com/storacha/sprue/pkg/internal/ipldutil" - "github.com/storacha/sprue/pkg/lib/errors" + "github.com/fil-forge/libforge/capabilities/access" + "github.com/fil-forge/ucantone/errors" + "github.com/fil-forge/ucantone/execution/bindexec" + "github.com/fil-forge/ucantone/ucan" + "github.com/ipfs/go-cid" "github.com/storacha/sprue/pkg/provisioning" delegation_store "github.com/storacha/sprue/pkg/store/delegation" + "go.uber.org/zap" ) const ( @@ -27,98 +18,62 @@ const ( InsufficientStorageErrorName = "InsufficientStorage" ) -// WithAccessDelegateMethod registers the access/delegate handler. -// This handler stores delegations for later retrieval. -func WithAccessDelegateMethod(delegationStore delegation_store.Store, provisioningSvc *provisioning.Service, logger *zap.Logger) server.Option { - return server.WithServiceMethod( - access.DelegateAbility, - server.Provide( - access.Delegate, - AccessDelegateHandler(delegationStore, provisioningSvc, logger), - ), - ) -} - -func AccessDelegateHandler(delegationStore delegation_store.Store, provisioningSvc *provisioning.Service, logger *zap.Logger) server.HandlerFunc[access.DelegateCaveats, access.DelegateOk, failure.IPLDBuilderFailure] { - log := logger.With(zap.String("handler", access.DelegateAbility)) - return func(ctx context.Context, - cap ucan.Capability[access.DelegateCaveats], - inv invocation.Invocation, - iCtx server.InvocationContext, - ) (result.Result[access.DelegateOk, failure.IPLDBuilderFailure], fx.Effects, error) { - agent := inv.Issuer().DID() - space, err := did.Parse(cap.With()) - if err != nil { - log.Warn("invalid resource", zap.String("resource", cap.With())) - return nil, nil, fmt.Errorf("invalid resource: %w", err) - } - delegations := cap.Nb().Delegations - - log := log.With( - zap.Stringer("agent", agent), - zap.Stringer("space", space), - ) - log.Debug("delegating access", zap.Stringer("agent", agent)) - - providers, err := provisioningSvc.ListServiceProviders(ctx, space) - if err != nil { - log.Error("failed to list service providers", zap.Error(err)) - return nil, nil, fmt.Errorf("listing service providers: %w", err) - } - if len(providers) == 0 { - return result.Error[access.DelegateOk, failure.IPLDBuilderFailure]( - errors.New(InsufficientStorageErrorName, "space has no storage provider"), - ), nil, nil - } +func NewAccessDelegateHandler(delegationStore delegation_store.Store, provisioningSvc *provisioning.Service, logger *zap.Logger) Handler { + log := logger.With(zap.String("handler", access.DelegateCommand)) + return Handler{ + Capability: access.Delegate, + Handler: bindexec.NewHandler(func( + req *bindexec.Request[*access.DelegateArguments], + res *bindexec.Response[*access.DelegateOK], + ) error { + args := req.Task().BindArguments() + agent := req.Invocation().Issuer().DID() + space := req.Invocation().Subject().DID() - inv.Proofs() + log := log.With( + zap.Stringer("agent", agent), + zap.Stringer("space", space), + ) + log.Debug("delegating access", zap.Stringer("agent", agent)) - dlgs, err := extractDelegations(cap, inv) - if err != nil { - log.Error("failed to extract delegations", zap.Error(err)) - return nil, nil, err - } + providers, err := provisioningSvc.ListServiceProviders(req.Context(), space) + if err != nil { + log.Error("failed to list service providers", zap.Error(err)) + return fmt.Errorf("listing service providers: %w", err) + } + if len(providers) == 0 { + return res.SetFailure(errors.New(InsufficientStorageErrorName, "space has no storage provider")) + } - cause, err := ipldutil.ToCID(inv.Link()) - if err != nil { - return nil, nil, err - } - err = delegationStore.PutMany(ctx, dlgs, cause) - if err != nil { - log.Error("failed to store delegations", zap.Error(err)) - return nil, nil, err - } + dlgs, err := extractDelegations(args, req.Metadata()) + if err != nil { + log.Error("failed to extract delegations", zap.Error(err)) + return err + } - // For a mock service, we just acknowledge receipt of the delegations - // In a real service, these would be stored for later retrieval - for _, key := range delegations.Keys { - link := delegations.Values[key] - if link != nil { - logger.Debug("stored delegation", zap.String("link", link.String())) + err = delegationStore.PutMany(req.Context(), dlgs, req.Invocation().Task().Link()) + if err != nil { + log.Error("failed to store delegations", zap.Error(err)) + return err } - } - return result.Ok[access.DelegateOk, failure.IPLDBuilderFailure](access.DelegateOk{}), nil, nil + return res.SetSuccess(&access.DelegateOK{}) + }), } } -func extractDelegations(cap ucan.Capability[access.DelegateCaveats], inv invocation.Invocation) ([]delegation.Delegation, error) { - br, err := blockstore.NewBlockReader(blockstore.WithBlocksIterator(inv.Blocks())) - if err != nil { - return nil, fmt.Errorf("creating block reader: %w", err) - } - dlgs := make([]delegation.Delegation, 0, len(cap.Nb().Delegations.Keys)) - for _, root := range cap.Nb().Delegations.Values { - block, ok, err := br.Get(root) - if err != nil { - return nil, fmt.Errorf("getting delegation root block %q: %w", root.String(), err) +func extractDelegations(args *access.DelegateArguments, meta ucan.Container) ([]ucan.Token, error) { + all := make(map[cid.Cid]ucan.Token, len(args.Delegations)) + if meta != nil { + for _, d := range meta.Delegations() { + all[d.Link()] = d } + } + dlgs := make([]ucan.Token, 0, len(args.Delegations)) + for _, link := range args.Delegations { + d, ok := all[link] if !ok { - return nil, errors.New(DelegationNotFoundErrorName, "delegation not found: %s", root.String()) - } - d, err := delegation.NewDelegation(block, br) - if err != nil { - return nil, fmt.Errorf("creating delegation view for root %s: %w", root.String(), err) + return nil, errors.New(DelegationNotFoundErrorName, "delegation not found: %s", link.String()) } dlgs = append(dlgs, d) } diff --git a/pkg/service/handlers/access_delegate_test.go b/pkg/service/handlers/access_delegate_test.go index 65d1475..9d97bb4 100644 --- a/pkg/service/handlers/access_delegate_test.go +++ b/pkg/service/handlers/access_delegate_test.go @@ -4,14 +4,15 @@ import ( "context" "testing" - "github.com/storacha/go-libstoracha/capabilities/access" - "github.com/storacha/go-ucanto/core/delegation" - "github.com/storacha/go-ucanto/core/invocation" - "github.com/storacha/go-ucanto/core/result" - "github.com/storacha/go-ucanto/did" - "github.com/storacha/go-ucanto/ucan" + "github.com/fil-forge/libforge/capabilities/access" + "github.com/fil-forge/libforge/didmailto" + "github.com/fil-forge/ucantone/did" + "github.com/fil-forge/ucantone/execution" + "github.com/fil-forge/ucantone/result" + "github.com/fil-forge/ucantone/ucan/delegation" + "github.com/fil-forge/ucantone/ucan/invocation" + "github.com/ipfs/go-cid" "github.com/storacha/sprue/internal/testutil" - "github.com/storacha/sprue/pkg/identity" "github.com/storacha/sprue/pkg/provisioning" consumermemory "github.com/storacha/sprue/pkg/store/consumer/memory" dlgmemory "github.com/storacha/sprue/pkg/store/delegation/memory" @@ -34,7 +35,7 @@ func newProvisionedService(t *testing.T, serviceDID did.DID, space did.DID) *pro consumerStore := consumermemory.New() subscriptionStore := subscriptionmemory.New() - account := mustMailtoDID(t, "test@example.com") + account := testutil.Must(didmailto.New("test@example.com"))(t) ps := provisioning.NewService( []did.DID{serviceDID}, @@ -52,152 +53,151 @@ func newProvisionedService(t *testing.T, serviceDID did.DID, space did.DID) *pro func TestAccessDelegateHandler(t *testing.T) { logger := zaptest.NewLogger(t) - t.Run("invalid resource DID", func(t *testing.T) { + t.Run("no providers for space", func(t *testing.T) { id := newTestIdentity(t) dlgStore := dlgmemory.New() ps := newTestProvisioningService(t, nil) - handler := AccessDelegateHandler(dlgStore, ps, logger) + handler := NewAccessDelegateHandler(dlgStore, ps, logger) - agent, err := identity.New("") - require.NoError(t, err) + agent := testutil.RandomSigner(t) + space := testutil.RandomSigner(t) + + args := access.DelegateArguments{Delegations: []cid.Cid{}} - cap := ucan.NewCapability( - access.DelegateAbility, - "not-a-did", - access.DelegateCaveats{ - Delegations: access.DelegationLinksModel{ - Keys: []string{}, - Values: map[string]ucan.Link{}, - }, - }, + inv, err := access.Delegate.Invoke( + agent, + space, + &args, + invocation.WithAudience(id.Signer), ) + require.NoError(t, err) - inv, err := invocation.Invoke(agent.Signer, id.Signer, cap) + req := execution.NewRequest(t.Context(), inv) + res, err := execution.NewResponse(req.Invocation().Task().Link(), execution.WithSigner(id.Signer)) require.NoError(t, err) - _, _, err = handler(context.Background(), cap, inv, nil) - require.Error(t, err) - require.Contains(t, err.Error(), "invalid resource") + err = handler.Handler(req, res) + require.NoError(t, err) + + _, fail := result.Unwrap(res.Receipt().Out()) + require.NotNil(t, fail) }) - t.Run("no providers for space", func(t *testing.T) { + t.Run("success with delegation", func(t *testing.T) { id := newTestIdentity(t) dlgStore := dlgmemory.New() - // No providers provisioned - ps := newTestProvisioningService(t, nil) - handler := AccessDelegateHandler(dlgStore, ps, logger) - agent, err := identity.New("") + space := testutil.RandomSigner(t) + + ps := newProvisionedService(t, id.Signer.DID(), space.DID()) + handler := NewAccessDelegateHandler(dlgStore, ps, logger) + + agent := testutil.RandomSigner(t) + + // Create a delegation from the space to the agent for some capability. + dlg, err := delegation.Delegate(space, agent, space, "/space/blob/add") require.NoError(t, err) - cap := ucan.NewCapability( - access.DelegateAbility, - agent.DID(), - access.DelegateCaveats{ - Delegations: access.DelegationLinksModel{ - Keys: []string{}, - Values: map[string]ucan.Link{}, - }, - }, + args := access.DelegateArguments{ + Delegations: []cid.Cid{dlg.Link()}, + } + + inv, err := access.Delegate.Invoke( + agent, + space, + &args, + invocation.WithAudience(id.Signer), ) + require.NoError(t, err) - inv, err := invocation.Invoke(agent.Signer, id.Signer, cap) + // Attach the delegation to the request metadata so extractDelegations can find it. + req := execution.NewRequest(t.Context(), inv, execution.WithDelegations(dlg)) + res, err := execution.NewResponse(req.Invocation().Task().Link(), execution.WithSigner(id.Signer)) require.NoError(t, err) - res, _, err := handler(context.Background(), cap, inv, nil) + err = handler.Handler(req, res) require.NoError(t, err) - _, fail := result.Unwrap(res) - require.NotNil(t, fail) + _, fail := result.Unwrap(res.Receipt().Out()) + require.Nil(t, fail) + + // Verify the delegation was stored. + page, err := dlgStore.ListByAudience(t.Context(), agent.DID()) + require.NoError(t, err) + require.Len(t, page.Results, 1) }) - t.Run("success with delegation", func(t *testing.T) { + t.Run("empty delegations with provisioned space", func(t *testing.T) { id := newTestIdentity(t) dlgStore := dlgmemory.New() - space, err := identity.New("") - require.NoError(t, err) + space := testutil.RandomSigner(t) - ps := newProvisionedService(t, id.Signer.DID(), space.Signer.DID()) - handler := AccessDelegateHandler(dlgStore, ps, logger) + ps := newProvisionedService(t, id.Signer.DID(), space.DID()) + handler := NewAccessDelegateHandler(dlgStore, ps, logger) - agent, err := identity.New("") - require.NoError(t, err) + agent := testutil.RandomSigner(t) - // Create a delegation from space to agent - dlg, err := delegation.Delegate( - space.Signer, - agent.Signer, - []ucan.Capability[ucan.NoCaveats]{ - ucan.NewCapability("space/blob/add", space.DID(), ucan.NoCaveats{}), - }, - ) - require.NoError(t, err) + args := access.DelegateArguments{Delegations: []cid.Cid{}} - k := dlg.Link().String() - cap := ucan.NewCapability( - access.DelegateAbility, - space.DID(), - access.DelegateCaveats{ - Delegations: access.DelegationLinksModel{ - Keys: []string{k}, - Values: map[string]ucan.Link{k: dlg.Link()}, - }, - }, + inv, err := access.Delegate.Invoke( + agent, + space, + &args, + invocation.WithAudience(id.Signer), ) + require.NoError(t, err) - // Create invocation and attach delegation blocks so extractDelegations can find them - inv, err := invocation.Invoke( - agent.Signer, - id.Signer, - cap, - delegation.WithProof(delegation.FromDelegation(dlg)), - ) + req := execution.NewRequest(t.Context(), inv) + res, err := execution.NewResponse(req.Invocation().Task().Link(), execution.WithSigner(id.Signer)) require.NoError(t, err) - res, _, err := handler(context.Background(), cap, inv, nil) + err = handler.Handler(req, res) require.NoError(t, err) - _, fail := result.Unwrap(res) + _, fail := result.Unwrap(res.Receipt().Out()) require.Nil(t, fail) - - // Verify the delegation was stored - page, err := dlgStore.ListByAudience(context.Background(), agent.Signer.DID()) - require.NoError(t, err) - require.Len(t, page.Results, 1) }) - t.Run("empty delegations with provisioned space", func(t *testing.T) { + t.Run("delegation not found in metadata", func(t *testing.T) { id := newTestIdentity(t) dlgStore := dlgmemory.New() - space, err := identity.New("") - require.NoError(t, err) + space := testutil.RandomSigner(t) - ps := newProvisionedService(t, id.Signer.DID(), space.Signer.DID()) - handler := AccessDelegateHandler(dlgStore, ps, logger) + ps := newProvisionedService(t, id.Signer.DID(), space.DID()) + handler := NewAccessDelegateHandler(dlgStore, ps, logger) - agent, err := identity.New("") + agent := testutil.RandomSigner(t) + + // Reference a delegation by CID, but don't include it in the request metadata. + // We still need at least one delegation in the request so req.Metadata() is non-nil. + other, err := delegation.Delegate(space, agent, space, "/other") require.NoError(t, err) - cap := ucan.NewCapability( - access.DelegateAbility, - space.DID(), - access.DelegateCaveats{ - Delegations: access.DelegationLinksModel{ - Keys: []string{}, - Values: map[string]ucan.Link{}, - }, - }, - ) + missing, err := delegation.Delegate(space, agent, space, "/space/blob/add") + require.NoError(t, err) + + args := access.DelegateArguments{ + Delegations: []cid.Cid{missing.Link()}, + } - inv, err := invocation.Invoke(agent.Signer, id.Signer, cap) + inv, err := access.Delegate.Invoke( + agent, + space, + &args, + invocation.WithAudience(id.Signer), + ) require.NoError(t, err) - res, _, err := handler(context.Background(), cap, inv, nil) + req := execution.NewRequest(t.Context(), inv, execution.WithDelegations(other)) + res, err := execution.NewResponse(req.Invocation().Task().Link(), execution.WithSigner(id.Signer)) require.NoError(t, err) - _, fail := result.Unwrap(res) - require.Nil(t, fail) + // extractDelegations returns an error directly (not via SetFailure) when a + // referenced delegation is missing from the request metadata. + err = handler.Handler(req, res) + require.Error(t, err) + require.Contains(t, err.Error(), "delegation not found") }) } diff --git a/pkg/service/handlers/access_request.go b/pkg/service/handlers/access_request.go new file mode 100644 index 0000000..66657df --- /dev/null +++ b/pkg/service/handlers/access_request.go @@ -0,0 +1,149 @@ +package handlers + +import ( + "fmt" + "net/url" + "time" + + "go.uber.org/zap" + + "github.com/fil-forge/libforge/capabilities/access" + "github.com/fil-forge/libforge/didmailto" + "github.com/fil-forge/ucantone/errors" + "github.com/fil-forge/ucantone/execution/bindexec" + "github.com/fil-forge/ucantone/ucan" + "github.com/fil-forge/ucantone/ucan/container" + "github.com/fil-forge/ucantone/ucan/invocation" + "github.com/fil-forge/ucantone/ucan/promise" + "github.com/storacha/sprue/internal/config" + "github.com/storacha/sprue/pkg/capabilities/admin/provider" + "github.com/storacha/sprue/pkg/identity" + "github.com/storacha/sprue/pkg/mailer" +) + +const ( + InvalidAuthorizationAccountErrorName = "InvalidAuthorizationAccount" + InvalidAuthorizationAudienceErrorName = "InvalidAuthorizationAudience" +) + +// Standard email flow - create confirmation delegation and send email +// We allow granting access within the next 15 minutes +const confirmationTTL = time.Minute * 15 + +var ( + ErrMissingAuthorizationAccount = errors.New(InvalidAuthorizationAccountErrorName, "missing authorization account DID") + ErrInvalidAuthorizationAccount = errors.New(InvalidAuthorizationAccountErrorName, "invalid authorization account DID") + ErrInvalidAuthorizationAudience = errors.New(InvalidAuthorizationAudienceErrorName, "invalid authorization audience DID") +) + +func NewAccessRequestHandler(serverCfg config.ServerConfig, id *identity.Identity, mailer mailer.Mailer, logger *zap.Logger) Handler { + log := logger.With(zap.String("handler", access.RequestCommand)) + return Handler{ + Capability: provider.List, + Handler: bindexec.NewHandler(func( + req *bindexec.Request[*access.RequestArguments], + res *bindexec.Response[*access.RequestOK], + ) error { + args := req.Task().BindArguments() + account, err := didmailto.Parse(args.Issuer.String()) + if err != nil { + log.Warn("failed to parse mailto DID", zap.Stringer("account", args.Issuer)) + return res.SetFailure(errors.New(InvalidAuthorizationAccountErrorName, "invalid authorization account DID: %v", err)) + } + // we should be able to extract the email from the DID since we just + // parsed it as a did:mailto: + email, err := didmailto.Email(account) + if err != nil { + log.Warn("failed to extract email from DID", zap.Stringer("account", args.Issuer)) + return res.SetFailure(errors.New(InvalidAuthorizationAccountErrorName, "invalid authorization account DID: %v", err)) + } + audience := req.Invocation().Subject().DID() + agent := req.Invocation().Issuer().DID() + log := log.With( + zap.Stringer("agent", agent), + zap.Stringer("account", account), + zap.Stringer("audience", audience), + ) + log.Debug("requesting access") + + exp := int(time.Now().Add(confirmationTTL).Unix()) + + // We issue an `/access/confirm` invocation which will be embedded in the + // URL that we send to the user. When the user clicks the link we'll get + // this invocation back in the `/validate-email` endpoint which will allow + // us to verify that it was the user who clicked the link and not some + // attacker impersonating the user. We will know that because the `subject` + // will be our service DID and only private key holder is able to issue + // such an invocation. + // + // We limit the lifetime of this UCAN to 15 minutes to reduce the attack + // surface where an attacker could attempt concurrent authorization + // requests in an attempt to confuse a user into clicking the wrong link. + confirmation, err := access.Confirm.Invoke( + id.Signer, + id.Signer, + // We link to the authorization request so that this invocation can + // not be used to authorize a different request. + &access.ConfirmArguments{ + // we copy request details and set the `aud` field to the agent DID + // that requested the authorization. + Issuer: account, + Audience: audience, + Attenuations: args.Attenuations, + // Link to the task that requested the authorization. + Cause: req.Invocation().Task().Link(), + }, + // audience same as issuer because this is a service invocation + // that will get handled by /access/confirm handler + // but only if the receiver of this email wants it to be + invocation.WithAudience(id.Signer), + invocation.WithExpiration(ucan.UTCUnixTimestamp(exp)), + // we copy the facts in so that information can be passed + // from the invoker of this capability to the invoker of the confirm + // capability - we use this, for example, to let bsky.storage users + // specify that they should be redirected back to bsky.storage after + // completing the Stripe plan selection flow + invocation.WithMetadata(req.Invocation().Metadata()), + ) + if err != nil { + log.Error("failed to create confirmation delegation", zap.Error(err)) + return fmt.Errorf("creating confirmation delegation: %w", err) + } + + confirmationStr, err := container.Encode( + container.Base64urlGzip, + container.New(container.WithInvocations(confirmation)), + ) + if err != nil { + log.Error("failed to format confirmation", zap.Error(err)) + return fmt.Errorf("formatting confirmation: %w", err) + } + + pubUrlStr := serverCfg.PublicURL + if pubUrlStr == "" { + pubUrlStr = fmt.Sprintf("http://%s:%d", serverCfg.Host, serverCfg.Port) + } + validationURL, err := url.Parse(fmt.Sprintf("%s/validate-email?ucan=%s&mode=authorize", pubUrlStr, confirmationStr)) + if err != nil { + log.Error("failed to parse validation URL", zap.Error(err)) + return fmt.Errorf("parsing validation URL: %w", err) + } + + err = mailer.SendValidation(req.Context(), email, *validationURL) + if err != nil { + log.Error("failed to send validation email", zap.Error(err)) + return fmt.Errorf("sending validation email: %w", err) + } + + return res.SetSuccess(&access.RequestOK{ + // link to this access request + Request: req.Invocation().Link(), + // link to the authorization confirmation so it could be used to lookup + // the delegation by the access request. + Confirm: promise.AwaitOK{Task: confirmation.Task().Link()}, + // let client know when the confirmation will expire + Expiration: int64(exp), + }) + }), + } +} diff --git a/pkg/service/handlers/access_request_test.go b/pkg/service/handlers/access_request_test.go new file mode 100644 index 0000000..9ef4387 --- /dev/null +++ b/pkg/service/handlers/access_request_test.go @@ -0,0 +1,210 @@ +package handlers + +import ( + "context" + "errors" + "net/url" + "testing" + + "github.com/fil-forge/libforge/capabilities/access" + adm "github.com/fil-forge/libforge/capabilities/access/datamodel" + "github.com/fil-forge/libforge/didmailto" + edm "github.com/fil-forge/ucantone/errors/datamodel" + "github.com/fil-forge/ucantone/execution" + "github.com/fil-forge/ucantone/ipld/datamodel" + "github.com/fil-forge/ucantone/result" + "github.com/fil-forge/ucantone/ucan/invocation" + "github.com/storacha/sprue/internal/config" + "github.com/storacha/sprue/internal/testutil" + "github.com/storacha/sprue/pkg/identity" + "github.com/stretchr/testify/require" + "go.uber.org/zap/zaptest" +) + +type mockMailer struct { + lastTo string + lastURL url.URL + err error +} + +func (m *mockMailer) SendValidation(ctx context.Context, to string, validationURL url.URL) error { + m.lastTo = to + m.lastURL = validationURL + return m.err +} + +func newTestIdentity(t *testing.T) *identity.Identity { + t.Helper() + id, err := identity.New("") + require.NoError(t, err) + return id +} + +func TestAccessRequestHandler(t *testing.T) { + logger := zaptest.NewLogger(t) + serverCfg := config.ServerConfig{ + Host: "localhost", + Port: 8080, + PublicURL: "http://localhost:8080", + } + + t.Run("success", func(t *testing.T) { + id := newTestIdentity(t) + m := &mockMailer{} + handler := NewAccessRequestHandler(serverCfg, id, m, logger) + + account, err := didmailto.New("alice@example.com") + require.NoError(t, err) + + args := access.RequestArguments{ + Issuer: account, + Attenuations: []adm.CapabilityRequestModel{ + {Command: "/"}, + }, + } + + agent := testutil.RandomSigner(t) + + inv, err := access.Request.Invoke( + agent, + id.Signer, + &args, + invocation.WithAudience(id.Signer), + ) + require.NoError(t, err) + + req := execution.NewRequest(t.Context(), inv) + res, err := execution.NewResponse(req.Invocation().Task().Link(), execution.WithSigner(id.Signer)) + require.NoError(t, err) + + err = handler.Handler(req, res) + require.NoError(t, err) + + o, x := result.Unwrap(res.Receipt().Out()) + require.Nil(t, x) + require.NotNil(t, o) + + ok := access.RequestOK{} + err = datamodel.Rebind(datamodel.NewAny(o), &ok) + require.NoError(t, err) + require.Equal(t, inv.Link(), ok.Request) + require.NotZero(t, ok.Expiration) + + require.Equal(t, "alice@example.com", m.lastTo) + require.Contains(t, m.lastURL.String(), "/validate-email") + require.Contains(t, m.lastURL.Query().Get("mode"), "authorize") + }) + + t.Run("invalid account DID", func(t *testing.T) { + id := newTestIdentity(t) + m := &mockMailer{} + handler := NewAccessRequestHandler(serverCfg, id, m, logger) + + // A did:key (not did:mailto) — didmailto.Parse will reject it. + nonMailtoSigner := testutil.RandomSigner(t) + args := access.RequestArguments{ + Issuer: nonMailtoSigner.DID(), + Attenuations: []adm.CapabilityRequestModel{ + {Command: "/"}, + }, + } + + agent := testutil.RandomSigner(t) + + inv, err := access.Request.Invoke( + agent, + id.Signer, + &args, + invocation.WithAudience(id.Signer), + ) + require.NoError(t, err) + + req := execution.NewRequest(t.Context(), inv) + res, err := execution.NewResponse(req.Invocation().Task().Link(), execution.WithSigner(id.Signer)) + require.NoError(t, err) + + err = handler.Handler(req, res) + require.NoError(t, err) + + _, x := result.Unwrap(res.Receipt().Out()) + require.NotNil(t, x) + + model := edm.ErrorModel{} + err = datamodel.Rebind(datamodel.NewAny(x), &model) + require.NoError(t, err) + require.Equal(t, InvalidAuthorizationAccountErrorName, model.Name()) + }) + + t.Run("mailer error", func(t *testing.T) { + id := newTestIdentity(t) + m := &mockMailer{err: errors.New("smtp failure")} + handler := NewAccessRequestHandler(serverCfg, id, m, logger) + + account, err := didmailto.New("alice@example.com") + require.NoError(t, err) + + args := access.RequestArguments{ + Issuer: account, + Attenuations: []adm.CapabilityRequestModel{ + {Command: "/"}, + }, + } + + agent := testutil.RandomSigner(t) + + inv, err := access.Request.Invoke( + agent, + id.Signer, + &args, + invocation.WithAudience(id.Signer), + ) + require.NoError(t, err) + + req := execution.NewRequest(t.Context(), inv) + res, err := execution.NewResponse(req.Invocation().Task().Link(), execution.WithSigner(id.Signer)) + require.NoError(t, err) + + err = handler.Handler(req, res) + require.Error(t, err) + require.Contains(t, err.Error(), "sending validation email") + }) + + t.Run("public URL fallback", func(t *testing.T) { + id := newTestIdentity(t) + m := &mockMailer{} + cfgNoPublicURL := config.ServerConfig{ + Host: "myhost", + Port: 9090, + } + handler := NewAccessRequestHandler(cfgNoPublicURL, id, m, logger) + + account, err := didmailto.New("bob@example.com") + require.NoError(t, err) + + args := access.RequestArguments{ + Issuer: account, + Attenuations: []adm.CapabilityRequestModel{ + {Command: "/"}, + }, + } + + agent := testutil.RandomSigner(t) + + inv, err := access.Request.Invoke( + agent, + id.Signer, + &args, + invocation.WithAudience(id.Signer), + ) + require.NoError(t, err) + + req := execution.NewRequest(t.Context(), inv) + res, err := execution.NewResponse(req.Invocation().Task().Link(), execution.WithSigner(id.Signer)) + require.NoError(t, err) + + err = handler.Handler(req, res) + require.NoError(t, err) + + require.Contains(t, m.lastURL.String(), "http://myhost:9090/validate-email") + }) +} diff --git a/pkg/service/handlers/admin_provider_deregister.go b/pkg/service/handlers/admin_provider_deregister.go index bee8cb4..72fb6e3 100644 --- a/pkg/service/handlers/admin_provider_deregister.go +++ b/pkg/service/handlers/admin_provider_deregister.go @@ -1,53 +1,39 @@ package handlers import ( - "context" - - "go.uber.org/zap" - - "github.com/storacha/go-ucanto/core/invocation" - "github.com/storacha/go-ucanto/core/receipt/fx" - "github.com/storacha/go-ucanto/core/result" - "github.com/storacha/go-ucanto/core/result/failure" - "github.com/storacha/go-ucanto/server" - "github.com/storacha/go-ucanto/ucan" + "github.com/fil-forge/ucantone/errors" + "github.com/fil-forge/ucantone/execution/bindexec" "github.com/storacha/sprue/pkg/capabilities/admin/provider" "github.com/storacha/sprue/pkg/identity" - "github.com/storacha/sprue/pkg/lib/errors" storageprovider "github.com/storacha/sprue/pkg/store/storage_provider" + "go.uber.org/zap" ) -// WithAdminProviderDeregisterMethod registers the admin/provider/deregister handler. -func WithAdminProviderDeregisterMethod(id *identity.Identity, providerStore storageprovider.Store, logger *zap.Logger) server.Option { - return server.WithServiceMethod( - provider.DeregisterAbility, - server.Provide( - provider.Deregister, - AdminProviderDeregisterHandler(id, providerStore, logger), - ), - ) -} +func NewAdminProviderDeregisterHandler(id *identity.Identity, providerStore storageprovider.Store, logger *zap.Logger) Handler { + log := logger.With(zap.String("handler", provider.DeregisterCommand)) + return Handler{ + Capability: provider.Deregister, + Handler: bindexec.NewHandler(func( + req *bindexec.Request[*provider.DeregisterArguments], + res *bindexec.Response[*provider.DeregisterOK], + ) error { + args := req.Task().BindArguments() -func AdminProviderDeregisterHandler(id *identity.Identity, providerStore storageprovider.Store, logger *zap.Logger) server.HandlerFunc[provider.DeregisterCaveats, provider.DeregisterOk, failure.IPLDBuilderFailure] { - log := logger.With(zap.String("handler", provider.DeregisterAbility)) - return func(ctx context.Context, - cap ucan.Capability[provider.DeregisterCaveats], - inv invocation.Invocation, - iCtx server.InvocationContext, - ) (result.Result[provider.DeregisterOk, failure.IPLDBuilderFailure], fx.Effects, error) { - args := cap.Nb() - if inv.Issuer().DID() != id.Signer.DID() { - log.Warn("Unauthorized access attempt", zap.Stringer("issuer", inv.Issuer().DID())) - return result.Error[provider.DeregisterOk, failure.IPLDBuilderFailure]( - errors.New("Unauthorized", "only the service identity can deregister a provider"), - ), nil, nil - } + if req.Invocation().Issuer().DID() != id.Signer.DID() { + log.Warn("Unauthorized access attempt", zap.Stringer("issuer", req.Invocation().Issuer().DID())) + return res.SetFailure(errors.New("Unauthorized", "only the service identity can deregister a provider")) + } - err := providerStore.Delete(ctx, args.Provider) - if err != nil { - log.Error("Failed to deregister provider", zap.Error(err)) - return nil, nil, err - } - return result.Ok[provider.DeregisterOk, failure.IPLDBuilderFailure](provider.DeregisterOk{}), nil, nil + err := providerStore.Delete(req.Context(), args.Provider) + if err != nil { + if errors.Is(err, storageprovider.ErrStorageProviderNotFound) { + log.Warn("Provider not found", zap.Stringer("provider", args.Provider)) + return res.SetFailure(err) + } + log.Error("Failed to deregister provider", zap.Error(err)) + return err + } + return res.SetSuccess(&provider.DeregisterOK{}) + }), } } diff --git a/pkg/service/handlers/admin_provider_deregister_test.go b/pkg/service/handlers/admin_provider_deregister_test.go new file mode 100644 index 0000000..9e86a8a --- /dev/null +++ b/pkg/service/handlers/admin_provider_deregister_test.go @@ -0,0 +1,149 @@ +package handlers_test + +import ( + "net/url" + "testing" + + edm "github.com/fil-forge/ucantone/errors/datamodel" + "github.com/fil-forge/ucantone/execution" + "github.com/fil-forge/ucantone/ipld/datamodel" + "github.com/fil-forge/ucantone/result" + "github.com/fil-forge/ucantone/ucan" + "github.com/fil-forge/ucantone/ucan/invocation" + "github.com/storacha/sprue/internal/testutil" + "github.com/storacha/sprue/pkg/capabilities/admin/provider" + "github.com/storacha/sprue/pkg/identity" + "github.com/storacha/sprue/pkg/service/handlers" + storageprovider "github.com/storacha/sprue/pkg/store/storage_provider" + storage_provider_store "github.com/storacha/sprue/pkg/store/storage_provider/memory" + "github.com/stretchr/testify/require" + "go.uber.org/zap/zaptest" +) + +func issueDeregisterInvocation( + t *testing.T, + issuer ucan.Signer, + audience ucan.Principal, + args provider.DeregisterArguments, +) execution.Request { + t.Helper() + + inv, err := provider.Deregister.Invoke( + issuer, + audience, + &args, + invocation.WithAudience(audience), + ) + require.NoError(t, err) + + return execution.NewRequest(t.Context(), inv) +} + +func TestAdminProviderDeregisterHandler(t *testing.T) { + logger := zaptest.NewLogger(t) + ctx := t.Context() + + uploadService := testutil.WebService + + t.Run("unauthorized issuer", func(t *testing.T) { + spStore := storage_provider_store.New() + + handler := handlers.NewAdminProviderDeregisterHandler( + &identity.Identity{Signer: uploadService}, spStore, logger, + ) + + storageProvider := testutil.RandomSigner(t) + unauthorizedIssuer := testutil.RandomSigner(t) + + // Pre-populate the store so we can verify the record is NOT removed. + endpoint, err := url.Parse("https://piri.example.com") + require.NoError(t, err) + err = spStore.Put(ctx, storageProvider.DID(), *endpoint, 0, nil) + require.NoError(t, err) + + args := provider.DeregisterArguments{ + Provider: storageProvider.DID(), + } + + req := issueDeregisterInvocation(t, unauthorizedIssuer, uploadService, args) + res, err := execution.NewResponse(req.Invocation().Task().Link(), execution.WithSigner(uploadService)) + require.NoError(t, err) + + err = handler.Handler(req, res) + require.NoError(t, err) + + _, fail := result.Unwrap(res.Receipt().Out()) + require.NotNil(t, fail) + + model := edm.ErrorModel{} + err = datamodel.Rebind(datamodel.NewAny(fail), &model) + require.NoError(t, err) + require.Equal(t, "Unauthorized", model.Name()) + + // Record should still be present. + _, err = spStore.Get(ctx, storageProvider.DID()) + require.NoError(t, err) + }) + + t.Run("service identity can deregister", func(t *testing.T) { + spStore := storage_provider_store.New() + + handler := handlers.NewAdminProviderDeregisterHandler( + &identity.Identity{Signer: uploadService}, spStore, logger, + ) + + storageProvider := testutil.RandomSigner(t) + + endpoint, err := url.Parse("https://piri.example.com") + require.NoError(t, err) + err = spStore.Put(ctx, storageProvider.DID(), *endpoint, 0, nil) + require.NoError(t, err) + + args := provider.DeregisterArguments{ + Provider: storageProvider.DID(), + } + + req := issueDeregisterInvocation(t, uploadService, uploadService, args) + res, err := execution.NewResponse(req.Invocation().Task().Link(), execution.WithSigner(uploadService)) + require.NoError(t, err) + + err = handler.Handler(req, res) + require.NoError(t, err) + + o, fail := result.Unwrap(res.Receipt().Out()) + require.Nil(t, fail) + require.NotNil(t, o) + + _, err = spStore.Get(ctx, storageProvider.DID()) + require.ErrorIs(t, err, storageprovider.ErrStorageProviderNotFound) + }) + + t.Run("provider not found", func(t *testing.T) { + spStore := storage_provider_store.New() + + handler := handlers.NewAdminProviderDeregisterHandler( + &identity.Identity{Signer: uploadService}, spStore, logger, + ) + + storageProvider := testutil.RandomSigner(t) + + args := provider.DeregisterArguments{ + Provider: storageProvider.DID(), + } + + req := issueDeregisterInvocation(t, uploadService, uploadService, args) + res, err := execution.NewResponse(req.Invocation().Task().Link(), execution.WithSigner(uploadService)) + require.NoError(t, err) + + err = handler.Handler(req, res) + require.NoError(t, err) + + _, fail := result.Unwrap(res.Receipt().Out()) + require.NotNil(t, fail) + + model := edm.ErrorModel{} + err = datamodel.Rebind(datamodel.NewAny(fail), &model) + require.NoError(t, err) + require.Equal(t, storageprovider.StorageProviderNotFoundErrorName, model.Name()) + }) +} diff --git a/pkg/service/handlers/admin_provider_list.go b/pkg/service/handlers/admin_provider_list.go index 5534a5a..a277db3 100644 --- a/pkg/service/handlers/admin_provider_list.go +++ b/pkg/service/handlers/admin_provider_list.go @@ -5,72 +5,54 @@ import ( "go.uber.org/zap" - "github.com/storacha/go-ucanto/core/invocation" - "github.com/storacha/go-ucanto/core/receipt/fx" - "github.com/storacha/go-ucanto/core/result" - "github.com/storacha/go-ucanto/core/result/failure" - "github.com/storacha/go-ucanto/server" - "github.com/storacha/go-ucanto/ucan" + "github.com/fil-forge/ucantone/errors" + "github.com/fil-forge/ucantone/execution/bindexec" "github.com/storacha/sprue/pkg/capabilities/admin/provider" "github.com/storacha/sprue/pkg/identity" - "github.com/storacha/sprue/pkg/lib/errors" "github.com/storacha/sprue/pkg/store" storageprovider "github.com/storacha/sprue/pkg/store/storage_provider" ) -// WithAdminProviderListMethod registers the admin/provider/list handler. -func WithAdminProviderListMethod(id *identity.Identity, providerStore storageprovider.Store, logger *zap.Logger) server.Option { - return server.WithServiceMethod( - provider.ListAbility, - server.Provide( - provider.List, - AdminProviderListHandler(id, providerStore, logger), - ), - ) -} - -func AdminProviderListHandler(id *identity.Identity, providerStore storageprovider.Store, logger *zap.Logger) server.HandlerFunc[provider.ListCaveats, provider.ListOk, failure.IPLDBuilderFailure] { - log := logger.With(zap.String("handler", provider.ListAbility)) - return func(ctx context.Context, - cap ucan.Capability[provider.ListCaveats], - inv invocation.Invocation, - iCtx server.InvocationContext, - ) (result.Result[provider.ListOk, failure.IPLDBuilderFailure], fx.Effects, error) { - if inv.Issuer().DID() != id.Signer.DID() { - log.Warn("Unauthorized access attempt", zap.Stringer("issuer", inv.Issuer().DID())) - return result.Error[provider.ListOk, failure.IPLDBuilderFailure]( - errors.New("Unauthorized", "only the service identity can list providers"), - ), nil, nil - } +func NewAdminProviderListHandler(id *identity.Identity, providerStore storageprovider.Store, logger *zap.Logger) Handler { + log := logger.With(zap.String("handler", provider.ListCommand)) + return Handler{ + Capability: provider.List, + Handler: bindexec.NewHandler(func( + req *bindexec.Request[*provider.ListArguments], + res *bindexec.Response[*provider.ListOK], + ) error { + if req.Invocation().Issuer().DID() != id.Signer.DID() { + log.Warn("Unauthorized access attempt", zap.Stringer("issuer", req.Invocation().Issuer().DID())) + return res.SetFailure(errors.New("Unauthorized", "only the service identity can list providers")) + } - records, err := store.Collect(ctx, func(ctx context.Context, options store.PaginationConfig) (store.Page[storageprovider.Record], error) { - opts := []storageprovider.ListOption{} - if options.Cursor != nil { - opts = append(opts, storageprovider.WithListCursor(*options.Cursor)) + records, err := store.Collect(req.Context(), func(ctx context.Context, options store.PaginationConfig) (store.Page[storageprovider.Record], error) { + opts := []storageprovider.ListOption{} + if options.Cursor != nil { + opts = append(opts, storageprovider.WithListCursor(*options.Cursor)) + } + return providerStore.List(ctx, opts...) + }) + if err != nil { + log.Error("Failed to list providers", zap.Error(err)) + return err } - return providerStore.List(ctx, opts...) - }) - if err != nil { - log.Error("Failed to list providers", zap.Error(err)) - return nil, nil, err - } - var providers []provider.Provider - for _, p := range records { - replicationWeight := p.Weight - if p.ReplicationWeight != nil { - replicationWeight = *p.ReplicationWeight + var providers []provider.Provider + for _, p := range records { + replicationWeight := p.Weight + if p.ReplicationWeight != nil { + replicationWeight = *p.ReplicationWeight + } + providers = append(providers, provider.Provider{ + Provider: p.Provider, + Endpoint: p.Endpoint.String(), + Weight: int64(p.Weight), + ReplicationWeight: int64(replicationWeight), + }) } - providers = append(providers, provider.Provider{ - ID: p.Provider, - Endpoint: p.Endpoint.String(), - Weight: p.Weight, - ReplicationWeight: replicationWeight, - }) - } - return result.Ok[provider.ListOk, failure.IPLDBuilderFailure]( - provider.ListOk{Providers: providers}, - ), nil, nil + return res.SetSuccess(&provider.ListOK{Providers: providers}) + }), } } diff --git a/pkg/service/handlers/admin_provider_list_test.go b/pkg/service/handlers/admin_provider_list_test.go new file mode 100644 index 0000000..648d610 --- /dev/null +++ b/pkg/service/handlers/admin_provider_list_test.go @@ -0,0 +1,149 @@ +package handlers_test + +import ( + "net/url" + "testing" + + edm "github.com/fil-forge/ucantone/errors/datamodel" + "github.com/fil-forge/ucantone/execution" + "github.com/fil-forge/ucantone/ipld/datamodel" + "github.com/fil-forge/ucantone/result" + "github.com/fil-forge/ucantone/ucan" + "github.com/fil-forge/ucantone/ucan/invocation" + "github.com/storacha/sprue/internal/testutil" + "github.com/storacha/sprue/pkg/capabilities/admin/provider" + "github.com/storacha/sprue/pkg/identity" + "github.com/storacha/sprue/pkg/service/handlers" + storage_provider_store "github.com/storacha/sprue/pkg/store/storage_provider/memory" + "github.com/stretchr/testify/require" + "go.uber.org/zap/zaptest" +) + +func issueListInvocation( + t *testing.T, + issuer ucan.Signer, + audience ucan.Principal, +) execution.Request { + t.Helper() + + args := provider.ListArguments{} + inv, err := provider.List.Invoke( + issuer, + audience, + &args, + invocation.WithAudience(audience), + ) + require.NoError(t, err) + + return execution.NewRequest(t.Context(), inv) +} + +func TestAdminProviderListHandler(t *testing.T) { + logger := zaptest.NewLogger(t) + ctx := t.Context() + + uploadService := testutil.WebService + + t.Run("unauthorized issuer", func(t *testing.T) { + spStore := storage_provider_store.New() + + handler := handlers.NewAdminProviderListHandler( + &identity.Identity{Signer: uploadService}, spStore, logger, + ) + + unauthorizedIssuer := testutil.RandomSigner(t) + + req := issueListInvocation(t, unauthorizedIssuer, uploadService) + res, err := execution.NewResponse(req.Invocation().Task().Link(), execution.WithSigner(uploadService)) + require.NoError(t, err) + + err = handler.Handler(req, res) + require.NoError(t, err) + + _, fail := result.Unwrap(res.Receipt().Out()) + require.NotNil(t, fail) + + model := edm.ErrorModel{} + err = datamodel.Rebind(datamodel.NewAny(fail), &model) + require.NoError(t, err) + require.Equal(t, "Unauthorized", model.Name()) + }) + + t.Run("empty list", func(t *testing.T) { + spStore := storage_provider_store.New() + + handler := handlers.NewAdminProviderListHandler( + &identity.Identity{Signer: uploadService}, spStore, logger, + ) + + req := issueListInvocation(t, uploadService, uploadService) + res, err := execution.NewResponse(req.Invocation().Task().Link(), execution.WithSigner(uploadService)) + require.NoError(t, err) + + err = handler.Handler(req, res) + require.NoError(t, err) + + ok, fail := result.Unwrap(res.Receipt().Out()) + require.Nil(t, fail) + require.NotNil(t, ok) + + listOK := provider.ListOK{} + err = datamodel.Rebind(datamodel.NewAny(ok), &listOK) + require.NoError(t, err) + require.Empty(t, listOK.Providers) + }) + + t.Run("returns registered providers", func(t *testing.T) { + spStore := storage_provider_store.New() + + handler := handlers.NewAdminProviderListHandler( + &identity.Identity{Signer: uploadService}, spStore, logger, + ) + + sp1 := testutil.RandomSigner(t) + sp2 := testutil.RandomSigner(t) + + endpoint1, err := url.Parse("https://piri-1.example.com") + require.NoError(t, err) + endpoint2, err := url.Parse("https://piri-2.example.com") + require.NoError(t, err) + + repWeight := 50 + require.NoError(t, spStore.Put(ctx, sp1.DID(), *endpoint1, 100, &repWeight)) + require.NoError(t, spStore.Put(ctx, sp2.DID(), *endpoint2, 200, nil)) + + req := issueListInvocation(t, uploadService, uploadService) + res, err := execution.NewResponse(req.Invocation().Task().Link(), execution.WithSigner(uploadService)) + require.NoError(t, err) + + err = handler.Handler(req, res) + require.NoError(t, err) + + ok, fail := result.Unwrap(res.Receipt().Out()) + require.Nil(t, fail) + require.NotNil(t, ok) + + listOK := provider.ListOK{} + err = datamodel.Rebind(datamodel.NewAny(ok), &listOK) + require.NoError(t, err) + require.Len(t, listOK.Providers, 2) + + byDID := map[string]provider.Provider{} + for _, p := range listOK.Providers { + byDID[p.Provider.String()] = p + } + + got1, ok1 := byDID[sp1.DID().String()] + require.True(t, ok1) + require.Equal(t, "https://piri-1.example.com", got1.Endpoint) + require.Equal(t, int64(100), got1.Weight) + require.Equal(t, int64(50), got1.ReplicationWeight) + + got2, ok2 := byDID[sp2.DID().String()] + require.True(t, ok2) + require.Equal(t, "https://piri-2.example.com", got2.Endpoint) + require.Equal(t, int64(200), got2.Weight) + // When ReplicationWeight is nil in the store, the handler defaults it to Weight. + require.Equal(t, int64(200), got2.ReplicationWeight) + }) +} diff --git a/pkg/service/handlers/admin_provider_register.go b/pkg/service/handlers/admin_provider_register.go index e3d4536..e843d9b 100644 --- a/pkg/service/handlers/admin_provider_register.go +++ b/pkg/service/handlers/admin_provider_register.go @@ -1,24 +1,14 @@ package handlers import ( - "context" - "fmt" "net/url" - "go.uber.org/zap" - - "github.com/storacha/go-ucanto/core/dag/blockstore" - "github.com/storacha/go-ucanto/core/delegation" - "github.com/storacha/go-ucanto/core/invocation" - "github.com/storacha/go-ucanto/core/receipt/fx" - "github.com/storacha/go-ucanto/core/result" - "github.com/storacha/go-ucanto/core/result/failure" - "github.com/storacha/go-ucanto/server" - "github.com/storacha/go-ucanto/ucan" + "github.com/fil-forge/ucantone/errors" + "github.com/fil-forge/ucantone/execution/bindexec" "github.com/storacha/sprue/pkg/capabilities/admin/provider" "github.com/storacha/sprue/pkg/identity" - "github.com/storacha/sprue/pkg/lib/errors" storageprovider "github.com/storacha/sprue/pkg/store/storage_provider" + "go.uber.org/zap" ) var ( @@ -26,69 +16,43 @@ var ( initialReplicationWeight = 0 ) -// WithAdminProviderRegisterMethod registers the admin/provider/register handler. -func WithAdminProviderRegisterMethod(id *identity.Identity, providerStore storageprovider.Store, logger *zap.Logger) server.Option { - return server.WithServiceMethod( - provider.RegisterAbility, - server.Provide( - provider.Register, - AdminProviderRegisterHandler(id, providerStore, logger), - ), - ) -} - -func AdminProviderRegisterHandler(id *identity.Identity, providerStore storageprovider.Store, logger *zap.Logger) server.HandlerFunc[provider.RegisterCaveats, provider.RegisterOk, failure.IPLDBuilderFailure] { - log := logger.With(zap.String("handler", provider.RegisterAbility)) - return func(ctx context.Context, - cap ucan.Capability[provider.RegisterCaveats], - inv invocation.Invocation, - iCtx server.InvocationContext, - ) (result.Result[provider.RegisterOk, failure.IPLDBuilderFailure], fx.Effects, error) { - args := cap.Nb() - - endpoint, err := url.Parse(args.Endpoint) - if err != nil { - log.Warn("Invalid endpoint", zap.String("endpoint", args.Endpoint), zap.Error(err)) - return result.Error[provider.RegisterOk, failure.IPLDBuilderFailure]( - errors.New("InvalidEndpoint", "parsing endpoint: %s", err.Error()), - ), nil, nil - } - bs, err := blockstore.NewBlockReader(blockstore.WithBlocksIterator(inv.Blocks())) - if err != nil { - log.Error("Failed to create block reader", zap.Error(err)) - return nil, nil, fmt.Errorf("creating block reader: %w", err) - } - proof, err := delegation.NewDelegationView(args.Proof, bs) - if err != nil { - log.Error("Failed to create proof delegation view", zap.Error(err)) - return nil, nil, fmt.Errorf("creating proof delegation view: %w", err) - } +func NewAdminProviderRegisterHandler(id *identity.Identity, providerStore storageprovider.Store, logger *zap.Logger) Handler { + log := logger.With(zap.String("handler", provider.RegisterCommand)) + return Handler{ + Capability: provider.Register, + Handler: bindexec.NewHandler(func( + req *bindexec.Request[*provider.RegisterArguments], + res *bindexec.Response[*provider.RegisterOK], + ) error { + args := req.Task().BindArguments() + if req.Invocation().Issuer().DID() != id.Signer.DID() { + log.Warn("Unauthorized access attempt", zap.Stringer("issuer", req.Invocation().Issuer().DID())) + return res.SetFailure(errors.New("Unauthorized", "only the service identity can register providers")) + } - if inv.Issuer().DID() != id.Signer.DID() && inv.Issuer().DID() != proof.Issuer().DID() { - log.Warn("Unauthorized access attempt", zap.Stringer("issuer", inv.Issuer().DID())) - return result.Error[provider.RegisterOk, failure.IPLDBuilderFailure]( - errors.New("Unauthorized", "only the service identity or the provider itself can register a provider"), - ), nil, nil - } + endpoint, err := url.Parse(args.Endpoint) + if err != nil { + log.Warn("Invalid endpoint", zap.String("endpoint", args.Endpoint), zap.Error(err)) + return res.SetFailure(errors.New("InvalidEndpoint", "parsing endpoint: %s", err.Error())) + } - _, err = providerStore.Get(ctx, proof.Issuer().DID()) - if err != nil { - if !errors.Is(err, storageprovider.ErrStorageProviderNotFound) { - log.Error("Failed to get existing provider", zap.Error(err)) - return nil, nil, err + _, err = providerStore.Get(req.Context(), args.Provider) + if err != nil { + if !errors.Is(err, storageprovider.ErrStorageProviderNotFound) { + log.Error("Failed to get existing provider", zap.Error(err)) + return err + } + } else { + log.Warn("Provider already registered", zap.Stringer("provider", args.Provider)) + return res.SetFailure(errors.New("ProviderAlreadyRegistered", "a provider with this DID is already registered")) } - } else { - log.Warn("Provider already registered", zap.Stringer("provider", proof.Issuer().DID())) - return result.Error[provider.RegisterOk, failure.IPLDBuilderFailure]( - errors.New("ProviderAlreadyRegistered", "a provider with this DID is already registered"), - ), nil, nil - } - err = providerStore.Put(ctx, *endpoint, proof, initialWeight, &initialReplicationWeight) - if err != nil { - log.Error("Failed to register provider", zap.Error(err)) - return nil, nil, err - } - return result.Ok[provider.RegisterOk, failure.IPLDBuilderFailure](provider.RegisterOk{}), nil, nil + err = providerStore.Put(req.Context(), args.Provider, *endpoint, initialWeight, &initialReplicationWeight) + if err != nil { + log.Error("Failed to register provider", zap.Error(err)) + return err + } + return res.SetSuccess(&provider.RegisterOK{}) + }), } } diff --git a/pkg/service/handlers/admin_provider_register_test.go b/pkg/service/handlers/admin_provider_register_test.go index e28283a..d2d248c 100644 --- a/pkg/service/handlers/admin_provider_register_test.go +++ b/pkg/service/handlers/admin_provider_register_test.go @@ -3,11 +3,12 @@ package handlers_test import ( "testing" - "github.com/storacha/go-ucanto/core/delegation" - "github.com/storacha/go-ucanto/core/invocation" - "github.com/storacha/go-ucanto/core/result" - "github.com/storacha/go-ucanto/core/result/failure/datamodel" - "github.com/storacha/go-ucanto/ucan" + edm "github.com/fil-forge/ucantone/errors/datamodel" + "github.com/fil-forge/ucantone/execution" + "github.com/fil-forge/ucantone/ipld/datamodel" + "github.com/fil-forge/ucantone/result" + "github.com/fil-forge/ucantone/ucan" + "github.com/fil-forge/ucantone/ucan/invocation" "github.com/storacha/sprue/internal/testutil" "github.com/storacha/sprue/pkg/capabilities/admin/provider" "github.com/storacha/sprue/pkg/identity" @@ -17,36 +18,24 @@ import ( "go.uber.org/zap/zaptest" ) -// issueRegisterInvocation creates an admin/provider/register invocation with -// the proof delegation blocks attached. +// issueRegisterInvocation creates an admin/provider/register invocation request func issueRegisterInvocation( t *testing.T, issuer ucan.Signer, audience ucan.Principal, - caveats provider.RegisterCaveats, - proof delegation.Delegation, -) (ucan.Capability[provider.RegisterCaveats], invocation.Invocation) { + args provider.RegisterArguments, +) execution.Request { t.Helper() inv, err := provider.Register.Invoke( - issuer, audience, - audience.DID().String(), - caveats, + issuer, + audience, + &args, + invocation.WithAudience(audience), ) require.NoError(t, err) - // Attach proof delegation blocks to the invocation - for blk, err := range proof.Blocks() { - require.NoError(t, err) - require.NoError(t, inv.Attach(blk)) - } - - cap := provider.Register.New( - audience.DID().String(), - caveats, - ) - - return cap, inv + return execution.NewRequest(t.Context(), inv) } func TestAdminProviderRegisterHandler(t *testing.T) { @@ -58,155 +47,104 @@ func TestAdminProviderRegisterHandler(t *testing.T) { t.Run("unauthorized issuer", func(t *testing.T) { spStore := storage_provider_store.New() - handler := handlers.AdminProviderRegisterHandler( + handler := handlers.NewAdminProviderRegisterHandler( &identity.Identity{Signer: uploadService}, spStore, logger, ) storageProvider := testutil.RandomSigner(t) unauthorizedIssuer := testutil.RandomSigner(t) - // Create a proof delegation from storageProvider to uploadService - proof, err := delegation.Delegate( - storageProvider, uploadService, - []ucan.Capability[ucan.NoCaveats]{ - ucan.NewCapability("blob/allocate", storageProvider.DID().String(), ucan.NoCaveats{}), - }, - ) - require.NoError(t, err) - - caveats := provider.RegisterCaveats{ + args := provider.RegisterArguments{ + Provider: storageProvider.DID(), Endpoint: "https://piri.example.com", - Proof: proof.Link(), } // Issuer is neither the service nor the provider - cap, inv := issueRegisterInvocation(t, unauthorizedIssuer, uploadService, caveats, proof) + req := issueRegisterInvocation(t, unauthorizedIssuer, uploadService, args) + res, err := execution.NewResponse(req.Invocation().Task().Link(), execution.WithSigner(uploadService)) + require.NoError(t, err) - res, _, err := handler(ctx, cap, inv, nil) + err = handler.Handler(req, res) require.NoError(t, err) - _, fail := result.Unwrap(res) + _, fail := result.Unwrap(res.Receipt().Out()) require.NotNil(t, fail) - model := datamodel.Bind(testutil.Must(fail.ToIPLD())(t)) - require.NotNil(t, model.Name) - require.Equal(t, "Unauthorized", *model.Name) + model := edm.ErrorModel{} + err = datamodel.Rebind(datamodel.NewAny(fail), &model) + require.NoError(t, err) + require.Equal(t, "Unauthorized", model.Name()) }) t.Run("provider already registered", func(t *testing.T) { spStore := storage_provider_store.New() - handler := handlers.AdminProviderRegisterHandler( + handler := handlers.NewAdminProviderRegisterHandler( &identity.Identity{Signer: uploadService}, spStore, logger, ) storageProvider := testutil.RandomSigner(t) - // Create a proof delegation - proof, err := delegation.Delegate( - storageProvider, uploadService, - []ucan.Capability[ucan.NoCaveats]{ - ucan.NewCapability("blob/allocate", storageProvider.DID().String(), ucan.NoCaveats{}), - }, - ) - require.NoError(t, err) - - caveats := provider.RegisterCaveats{ + args := provider.RegisterArguments{ + Provider: storageProvider.DID(), Endpoint: "https://piri.example.com", - Proof: proof.Link(), } // First registration by service identity (authorized) - cap, inv := issueRegisterInvocation(t, uploadService, uploadService, caveats, proof) - res, _, err := handler(ctx, cap, inv, nil) + req := issueRegisterInvocation(t, uploadService, uploadService, args) + res, err := execution.NewResponse(req.Invocation().Task().Link(), execution.WithSigner(uploadService)) require.NoError(t, err) - o, fail := result.Unwrap(res) - require.Nil(t, fail) - require.NotNil(t, o) - - // Second registration should fail - cap2, inv2 := issueRegisterInvocation(t, uploadService, uploadService, caveats, proof) - res2, _, err := handler(ctx, cap2, inv2, nil) + err = handler.Handler(req, res) require.NoError(t, err) - _, fail2 := result.Unwrap(res2) - require.NotNil(t, fail2) - - model := datamodel.Bind(testutil.Must(fail2.ToIPLD())(t)) - require.NotNil(t, model.Name) - require.Equal(t, "ProviderAlreadyRegistered", *model.Name) - }) - - t.Run("service identity can register", func(t *testing.T) { - spStore := storage_provider_store.New() - - handler := handlers.AdminProviderRegisterHandler( - &identity.Identity{Signer: uploadService}, spStore, logger, - ) - - storageProvider := testutil.RandomSigner(t) + o, x := result.Unwrap(res.Receipt().Out()) + require.Nil(t, x) + require.NotNil(t, o) - proof, err := delegation.Delegate( - storageProvider, uploadService, - []ucan.Capability[ucan.NoCaveats]{ - ucan.NewCapability("blob/allocate", storageProvider.DID().String(), ucan.NoCaveats{}), - }, - ) + // Second registration should fail + req2 := issueRegisterInvocation(t, uploadService, uploadService, args) + res2, err := execution.NewResponse(req2.Invocation().Task().Link(), execution.WithSigner(uploadService)) require.NoError(t, err) - caveats := provider.RegisterCaveats{ - Endpoint: "https://piri.example.com", - Proof: proof.Link(), - } - - cap, inv := issueRegisterInvocation(t, uploadService, uploadService, caveats, proof) - - res, _, err := handler(ctx, cap, inv, nil) + err = handler.Handler(req2, res2) require.NoError(t, err) - o, fail := result.Unwrap(res) - require.Nil(t, fail) - require.NotNil(t, o) + _, x2 := result.Unwrap(res2.Receipt().Out()) + require.NotNil(t, x2) - // Verify provider was stored - rec, err := spStore.Get(ctx, storageProvider.DID()) + model := edm.ErrorModel{} + err = datamodel.Rebind(datamodel.NewAny(x2), &model) require.NoError(t, err) - require.Equal(t, "https://piri.example.com", rec.Endpoint.String()) + require.Equal(t, "ProviderAlreadyRegistered", model.Name()) }) - t.Run("provider itself can register", func(t *testing.T) { + t.Run("service identity can register", func(t *testing.T) { spStore := storage_provider_store.New() - handler := handlers.AdminProviderRegisterHandler( + handler := handlers.NewAdminProviderRegisterHandler( &identity.Identity{Signer: uploadService}, spStore, logger, ) storageProvider := testutil.RandomSigner(t) - proof, err := delegation.Delegate( - storageProvider, uploadService, - []ucan.Capability[ucan.NoCaveats]{ - ucan.NewCapability("blob/allocate", storageProvider.DID().String(), ucan.NoCaveats{}), - }, - ) - require.NoError(t, err) - - caveats := provider.RegisterCaveats{ + args := provider.RegisterArguments{ + Provider: storageProvider.DID(), Endpoint: "https://piri.example.com", - Proof: proof.Link(), } - // Issued by the provider itself - cap, inv := issueRegisterInvocation(t, storageProvider, uploadService, caveats, proof) + req := issueRegisterInvocation(t, uploadService, uploadService, args) + res, err := execution.NewResponse(req.Invocation().Task().Link(), execution.WithSigner(uploadService)) + require.NoError(t, err) - res, _, err := handler(ctx, cap, inv, nil) + err = handler.Handler(req, res) require.NoError(t, err) - o, fail := result.Unwrap(res) - require.Nil(t, fail) + o, x := result.Unwrap(res.Receipt().Out()) + require.Nil(t, x) require.NotNil(t, o) + // Verify provider was stored rec, err := spStore.Get(ctx, storageProvider.DID()) require.NoError(t, err) require.Equal(t, "https://piri.example.com", rec.Endpoint.String()) diff --git a/pkg/service/handlers/admin_provider_weight_set.go b/pkg/service/handlers/admin_provider_weight_set.go index baa7373..7747f2d 100644 --- a/pkg/service/handlers/admin_provider_weight_set.go +++ b/pkg/service/handlers/admin_provider_weight_set.go @@ -1,59 +1,46 @@ package handlers import ( - "context" - "go.uber.org/zap" - "github.com/storacha/go-ucanto/core/invocation" - "github.com/storacha/go-ucanto/core/receipt/fx" - "github.com/storacha/go-ucanto/core/result" - "github.com/storacha/go-ucanto/core/result/failure" - "github.com/storacha/go-ucanto/server" - "github.com/storacha/go-ucanto/ucan" - "github.com/storacha/sprue/pkg/capabilities/admin/provider" + "github.com/fil-forge/ucantone/errors" + "github.com/fil-forge/ucantone/execution/bindexec" + "github.com/storacha/sprue/pkg/capabilities/admin/provider/weight" "github.com/storacha/sprue/pkg/identity" - "github.com/storacha/sprue/pkg/lib/errors" storageprovider "github.com/storacha/sprue/pkg/store/storage_provider" ) -// WithAdminProviderWeightSetMethod registers the admin/provider/weight/set handler. -func WithAdminProviderWeightSetMethod(id *identity.Identity, providerStore storageprovider.Store, logger *zap.Logger) server.Option { - return server.WithServiceMethod( - provider.WeightSetAbility, - server.Provide( - provider.WeightSet, - AdminProviderWeightSetHandler(id, providerStore, logger), - ), - ) -} - -func AdminProviderWeightSetHandler(id *identity.Identity, providerStore storageprovider.Store, logger *zap.Logger) server.HandlerFunc[provider.WeightSetCaveats, provider.WeightSetOk, failure.IPLDBuilderFailure] { - log := logger.With(zap.String("handler", provider.WeightSetAbility)) - return func(ctx context.Context, - cap ucan.Capability[provider.WeightSetCaveats], - inv invocation.Invocation, - iCtx server.InvocationContext, - ) (result.Result[provider.WeightSetOk, failure.IPLDBuilderFailure], fx.Effects, error) { - args := cap.Nb() - if inv.Issuer().DID() != id.Signer.DID() { - log.Warn("Unauthorized access attempt", zap.Stringer("issuer", inv.Issuer().DID())) - return result.Error[provider.WeightSetOk, failure.IPLDBuilderFailure]( - errors.New("Unauthorized", "only the service identity can set provider weights"), - ), nil, nil - } +func NewAdminProviderWeightSetHandler(id *identity.Identity, providerStore storageprovider.Store, logger *zap.Logger) Handler { + log := logger.With(zap.String("handler", weight.SetCommand)) + return Handler{ + Capability: weight.Set, + Handler: bindexec.NewHandler(func( + req *bindexec.Request[*weight.SetArguments], + res *bindexec.Response[*weight.SetOK], + ) error { + args := req.Task().BindArguments() + if req.Invocation().Issuer().DID() != id.Signer.DID() { + log.Warn("Unauthorized access attempt", zap.Stringer("issuer", req.Invocation().Issuer().DID())) + return res.SetFailure(errors.New("Unauthorized", "only the service identity can set provider weights")) + } - p, err := providerStore.Get(ctx, args.Provider) - if err != nil { - log.Error("Failed to get existing provider", zap.Error(err)) - return nil, nil, err - } + p, err := providerStore.Get(req.Context(), args.Provider) + if err != nil { + log.Error("Failed to get existing provider", zap.Error(err)) + return res.SetFailure(errors.New("Failed to get existing provider", err.Error())) + } - err = providerStore.Put(ctx, p.Endpoint, p.Proof, args.Weight, &args.ReplicationWeight) - if err != nil { - log.Error("Failed to update provider weights", zap.Error(err)) - return nil, nil, err - } - return result.Ok[provider.WeightSetOk, failure.IPLDBuilderFailure](provider.WeightSetOk{}), nil, nil + replicationWeight := int(args.ReplicationWeight) + err = providerStore.Put(req.Context(), p.Provider, p.Endpoint, int(args.Weight), &replicationWeight) + if err != nil { + if errors.Is(err, storageprovider.ErrStorageProviderNotFound) { + log.Warn("Provider not found", zap.Stringer("provider", args.Provider)) + return res.SetFailure(err) + } + log.Error("Failed to update provider weights", zap.Error(err)) + return err + } + return res.SetSuccess(&weight.SetOK{}) + }), } } diff --git a/pkg/service/handlers/admin_provider_weight_set_test.go b/pkg/service/handlers/admin_provider_weight_set_test.go index 98d150c..01cef61 100644 --- a/pkg/service/handlers/admin_provider_weight_set_test.go +++ b/pkg/service/handlers/admin_provider_weight_set_test.go @@ -4,12 +4,14 @@ import ( "net/url" "testing" - "github.com/storacha/go-ucanto/core/delegation" - "github.com/storacha/go-ucanto/core/result" - "github.com/storacha/go-ucanto/core/result/failure/datamodel" - "github.com/storacha/go-ucanto/ucan" + edm "github.com/fil-forge/ucantone/errors/datamodel" + "github.com/fil-forge/ucantone/execution" + "github.com/fil-forge/ucantone/ipld/datamodel" + "github.com/fil-forge/ucantone/result" + "github.com/fil-forge/ucantone/ucan" + "github.com/fil-forge/ucantone/ucan/invocation" "github.com/storacha/sprue/internal/testutil" - "github.com/storacha/sprue/pkg/capabilities/admin/provider" + "github.com/storacha/sprue/pkg/capabilities/admin/provider/weight" "github.com/storacha/sprue/pkg/identity" "github.com/storacha/sprue/pkg/service/handlers" storage_provider_store "github.com/storacha/sprue/pkg/store/storage_provider/memory" @@ -17,6 +19,25 @@ import ( "go.uber.org/zap/zaptest" ) +func issueWeightSetInvocation( + t *testing.T, + issuer ucan.Signer, + audience ucan.Principal, + args weight.SetArguments, +) execution.Request { + t.Helper() + + inv, err := weight.Set.Invoke( + issuer, + audience, + &args, + invocation.WithAudience(audience), + ) + require.NoError(t, err) + + return execution.NewRequest(t.Context(), inv) +} + func TestAdminProviderWeightSetHandler(t *testing.T) { logger := zaptest.NewLogger(t) ctx := t.Context() @@ -26,115 +47,100 @@ func TestAdminProviderWeightSetHandler(t *testing.T) { t.Run("unauthorized issuer", func(t *testing.T) { spStore := storage_provider_store.New() - handler := handlers.AdminProviderWeightSetHandler( + handler := handlers.NewAdminProviderWeightSetHandler( &identity.Identity{Signer: uploadService}, spStore, logger, ) storageProvider := testutil.RandomSigner(t) unauthorizedIssuer := testutil.RandomSigner(t) - caveats := provider.WeightSetCaveats{ + args := weight.SetArguments{ Provider: storageProvider.DID(), Weight: 50, ReplicationWeight: 25, } - inv, err := provider.WeightSet.Invoke( - unauthorizedIssuer, uploadService, - uploadService.DID().String(), - caveats, - ) + req := issueWeightSetInvocation(t, unauthorizedIssuer, uploadService, args) + res, err := execution.NewResponse(req.Invocation().Task().Link(), execution.WithSigner(uploadService)) require.NoError(t, err) - cap := provider.WeightSet.New(uploadService.DID().String(), caveats) - - res, _, err := handler(ctx, cap, inv, nil) + err = handler.Handler(req, res) require.NoError(t, err) - _, fail := result.Unwrap(res) - require.NotNil(t, fail) + _, x := result.Unwrap(res.Receipt().Out()) + require.NotNil(t, x) - model := datamodel.Bind(testutil.Must(fail.ToIPLD())(t)) - require.NotNil(t, model.Name) - require.Equal(t, "Unauthorized", *model.Name) + model := edm.ErrorModel{} + err = datamodel.Rebind(datamodel.NewAny(x), &model) + require.NoError(t, err) + require.Equal(t, "Unauthorized", model.Name()) }) t.Run("provider not found", func(t *testing.T) { spStore := storage_provider_store.New() - handler := handlers.AdminProviderWeightSetHandler( + handler := handlers.NewAdminProviderWeightSetHandler( &identity.Identity{Signer: uploadService}, spStore, logger, ) storageProvider := testutil.RandomSigner(t) - caveats := provider.WeightSetCaveats{ + args := weight.SetArguments{ Provider: storageProvider.DID(), Weight: 50, ReplicationWeight: 25, } - inv, err := provider.WeightSet.Invoke( - uploadService, uploadService, - uploadService.DID().String(), - caveats, - ) + req := issueWeightSetInvocation(t, uploadService, uploadService, args) + res, err := execution.NewResponse(req.Invocation().Task().Link(), execution.WithSigner(uploadService)) require.NoError(t, err) - cap := provider.WeightSet.New(uploadService.DID().String(), caveats) + err = handler.Handler(req, res) + require.NoError(t, err) - // Provider is not registered, so Get will fail - _, _, err = handler(ctx, cap, inv, nil) - require.Error(t, err) + _, x := result.Unwrap(res.Receipt().Out()) + require.NotNil(t, x) + + model := edm.ErrorModel{} + err = datamodel.Rebind(datamodel.NewAny(x), &model) + require.NoError(t, err) + require.Equal(t, "Failed to get existing provider", model.Name()) }) t.Run("success updates weights", func(t *testing.T) { spStore := storage_provider_store.New() - handler := handlers.AdminProviderWeightSetHandler( + handler := handlers.NewAdminProviderWeightSetHandler( &identity.Identity{Signer: uploadService}, spStore, logger, ) storageProvider := testutil.RandomSigner(t) - endpoint := testutil.Must(url.Parse("https://piri.example.com"))(t) - - proof, err := delegation.Delegate( - storageProvider, uploadService, - []ucan.Capability[ucan.NoCaveats]{ - ucan.NewCapability("blob/allocate", storageProvider.DID().String(), ucan.NoCaveats{}), - }, - ) + endpoint, err := url.Parse("https://piri.example.com") require.NoError(t, err) - // Pre-register the provider with initial weights + // Pre-register the provider with initial weights. initialReplWeight := 0 - err = spStore.Put(ctx, *endpoint, proof, 0, &initialReplWeight) + err = spStore.Put(ctx, storageProvider.DID(), *endpoint, 0, &initialReplWeight) require.NoError(t, err) - // Set new weights - caveats := provider.WeightSetCaveats{ + args := weight.SetArguments{ Provider: storageProvider.DID(), Weight: 75, ReplicationWeight: 30, } - inv, err := provider.WeightSet.Invoke( - uploadService, uploadService, - uploadService.DID().String(), - caveats, - ) + req := issueWeightSetInvocation(t, uploadService, uploadService, args) + res, err := execution.NewResponse(req.Invocation().Task().Link(), execution.WithSigner(uploadService)) require.NoError(t, err) - cap := provider.WeightSet.New(uploadService.DID().String(), caveats) - - res, _, err := handler(ctx, cap, inv, nil) + err = handler.Handler(req, res) require.NoError(t, err) - o, fail := result.Unwrap(res) - require.Nil(t, fail) + o, x := result.Unwrap(res.Receipt().Out()) + require.Nil(t, x) require.NotNil(t, o) - // Verify weights were updated + // Verify weights were updated. rec, err := spStore.Get(ctx, storageProvider.DID()) require.NoError(t, err) require.Equal(t, 75, rec.Weight) diff --git a/pkg/service/handlers/blob_add.go b/pkg/service/handlers/blob_add.go new file mode 100644 index 0000000..8db438b --- /dev/null +++ b/pkg/service/handlers/blob_add.go @@ -0,0 +1,415 @@ +package handlers + +import ( + "context" + "crypto/ed25519" + "fmt" + + blobcaps "github.com/fil-forge/libforge/capabilities/blob" + httpcaps "github.com/fil-forge/libforge/capabilities/http" + "github.com/fil-forge/libforge/digestutil" + "github.com/fil-forge/ucantone/did" + "github.com/fil-forge/ucantone/errors" + "github.com/fil-forge/ucantone/execution/bindexec" + "github.com/fil-forge/ucantone/ipld" + "github.com/fil-forge/ucantone/ipld/datamodel" + "github.com/fil-forge/ucantone/principal" + ed25519signer "github.com/fil-forge/ucantone/principal/ed25519" + "github.com/fil-forge/ucantone/result" + "github.com/fil-forge/ucantone/ucan" + "github.com/fil-forge/ucantone/ucan/container" + "github.com/fil-forge/ucantone/ucan/invocation" + "github.com/fil-forge/ucantone/ucan/promise" + "github.com/fil-forge/ucantone/ucan/receipt" + "github.com/multiformats/go-multihash" + "github.com/storacha/sprue/pkg/identity" + "github.com/storacha/sprue/pkg/lib/ucan_server" + "github.com/storacha/sprue/pkg/piriclient" + "github.com/storacha/sprue/pkg/provisioning" + "github.com/storacha/sprue/pkg/routing" + "github.com/storacha/sprue/pkg/store/agent" + blobregistry "github.com/storacha/sprue/pkg/store/blob_registry" + "go.uber.org/zap" +) + +func NewBlobAddHandler(id *identity.Identity, provisioningSvc *provisioning.Service, router *routing.Service, nodeProvider piriclient.Provider, agentStore agent.Store, blobRegistry blobregistry.Store, logger *zap.Logger) Handler { + log := logger.With(zap.String("handler", blobcaps.AddCommand)) + return Handler{ + Capability: blobcaps.Add, + Handler: bindexec.NewHandler(func( + req *bindexec.Request[*blobcaps.AddArguments], + res *bindexec.Response[*blobcaps.AddOK], + ) error { + args := req.Task().BindArguments() + blob := args.Blob + space := req.Invocation().Subject().DID() + b58digest := digestutil.Format(blob.Digest) + + log := log.With( + zap.Stringer("space", space), + zap.Dict( + "blob", + zap.String("digest", b58digest), + zap.Uint64("size", blob.Size), + ), + ) + log.Debug("adding blob") + + providers, err := provisioningSvc.ListServiceProviders(req.Context(), space) + if err != nil { + log.Error("failed to list service providers", zap.Error(err)) + return fmt.Errorf("listing service providers: %w", err) + } + if len(providers) == 0 { + return res.SetFailure(errors.New(InsufficientStorageErrorName, "space has no storage provider")) + } + + reg, err := blobRegistry.Get(req.Context(), space, blob.Digest) + if err != nil { + if !errors.Is(err, blobregistry.ErrEntryNotFound) { + log.Error("failed to get blob registration", zap.Error(err)) + return err + } + } + + // If blob is already registered in the space, we can skip allocation and + // return the information from the original receipt, plus the invocations + // and receipts for the /blob/allocate /http/put and /blob/accept tasks + // that happened. + if err == nil { + log.Debug("blob already registered in space") + + // blob registration cause is the CID of the `/space/blob/add` task + addRcpt, err := agentStore.GetReceipt(req.Context(), reg.Cause) + if err != nil { + log.Error("failed to get receipt for blob registration", zap.Error(err)) + return err + } + + addOK, err := result.MatchResultR2( + addRcpt.Out(), + func(o ipld.Any) (*blobcaps.AddOK, error) { + var addOK blobcaps.AddOK + err := datamodel.Rebind(datamodel.NewAny(o), &addOK) + if err != nil { + log.Error("failed to rebind add OK result", zap.Error(err)) + return nil, fmt.Errorf("rebinding add OK result: %w", err) + } + return &addOK, nil + }, + func(x ipld.Any) (*blobcaps.AddOK, error) { + // should not have been registered on error + log.Error("blob registration receipt contains failure", zap.Any("error", x)) + return nil, fmt.Errorf("blob registration receipt contains failure: %v", x) + }, + ) + if err != nil { + log.Error("failed to match blob add receipt result", zap.Error(err)) + return fmt.Errorf("matching blob add receipt result: %w", err) + } + + accRcpt, err := agentStore.GetReceipt(req.Context(), addOK.Site.Task) + if err != nil { + log.Error("failed to get receipt for blob accept", zap.Error(err)) + return fmt.Errorf("getting receipt for blob accept: %w", err) + } + + accInv, err := agentStore.GetInvocation(req.Context(), addOK.Site.Task) + if err != nil { + log.Error("failed to get invocation for blob accept", zap.Error(err)) + return fmt.Errorf("getting invocation for blob accept: %w", err) + } + + accArgs := blobcaps.AcceptArguments{} + err = datamodel.Rebind(datamodel.NewAny(accInv.Arguments()), &accArgs) + if err != nil { + log.Error("failed to rebind accept OK result", zap.Error(err)) + return fmt.Errorf("rebinding accept OK result: %w", err) + } + + putRcpt, err := agentStore.GetReceipt(req.Context(), accArgs.Put.Task) + if err != nil { + log.Error("failed to get receipt for HTTP PUT", zap.Error(err)) + return fmt.Errorf("getting receipt for HTTP PUT: %w", err) + } + + putInv, err := agentStore.GetInvocation(req.Context(), accArgs.Put.Task) + if err != nil { + log.Error("failed to get invocation for HTTP PUT", zap.Error(err)) + return fmt.Errorf("getting invocation for HTTP PUT: %w", err) + } + + putArgs := httpcaps.PutArguments{} + err = datamodel.Rebind(datamodel.NewAny(putInv.Arguments()), &putArgs) + if err != nil { + log.Error("failed to rebind HTTP PUT arguments", zap.Error(err)) + return fmt.Errorf("rebinding HTTP PUT arguments: %w", err) + } + + allocRcpt, err := agentStore.GetReceipt(req.Context(), putArgs.Destination.Task) + if err != nil { + log.Error("failed to get receipt for allocation", zap.Error(err)) + return fmt.Errorf("getting receipt for allocation: %w", err) + } + + allocInv, err := agentStore.GetInvocation(req.Context(), putArgs.Destination.Task) + if err != nil { + log.Error("failed to get invocation for allocation", zap.Error(err)) + return fmt.Errorf("getting invocation for allocation: %w", err) + } + + res.SetMetadata(container.New( + container.WithInvocations(allocInv, putInv, accInv), + container.WithReceipts(allocRcpt, putRcpt, accRcpt), + )) + + return res.SetSuccess(addOK) + } + + cause := req.Invocation().Task().Link() + proofStore := ucan_server.NewContainerProofStore(req.Metadata()) + provider, allocInv, allocRcpt, allocOK, err := doAllocate(req.Context(), router, nodeProvider, agentStore, space, blob, cause, proofStore, log) + if err != nil { + if errors.Is(err, routing.ErrCandidateUnavailable) { + return res.SetFailure(routing.ErrCandidateUnavailable) + } + log.Error("allocation failed", zap.Error(err)) + return fmt.Errorf("allocating space: %w", err) + } + log = log.With(zap.Stringer("provider", provider.ID.DID())) + + putInv, putRcpt, err := genPut(blob, allocInv, allocOK, log) + if err != nil { + log.Error("failed to generate put invocation", zap.Error(err)) + return fmt.Errorf("generating put invocation: %w", err) + } + + accInv, accRcpt, err := maybeAccept(req.Context(), agentStore, blobRegistry, nodeProvider, provider, space, blob, cause, putInv, putRcpt, proofStore, log) + if err != nil { + return err + } + + metaOpts := []container.Option{container.WithInvocations(allocInv, putInv, accInv)} + for _, rcpt := range []ucan.Receipt{allocRcpt, putRcpt, accRcpt} { + if rcpt != nil { + metaOpts = append(metaOpts, container.WithReceipts(rcpt)) + } + } + res.SetMetadata(container.New(metaOpts...)) + + return res.SetSuccess(&blobcaps.AddOK{ + Site: promise.AwaitOK{ + Task: accInv.Task().Link(), + }, + }) + }), + } +} + +func doAllocate( + ctx context.Context, + router *routing.Service, + nodeProvider piriclient.Provider, + agentStore agent.Store, + space did.DID, + blob blobcaps.Blob, + cause ucan.Link, + proofStore ucan_server.ProofStore, + logger *zap.Logger, +) (routing.StorageProviderInfo, ucan.Invocation, ucan.Receipt, blobcaps.AllocateOK, error) { + log := logger.With(zap.Stringer("cause", cause)) + log.Debug("doing allocation") + + var exclusions []ucan.Principal + for { + candidate, err := router.SelectStorageProvider(ctx, blob, routing.WithExclusions(exclusions...)) + if err != nil { + log.Error("failed to select storage node", zap.Error(err)) + return routing.StorageProviderInfo{}, nil, nil, blobcaps.AllocateOK{}, err + } + log := logger.With(zap.Stringer("candidate", candidate.ID.DID()), zap.String("endpoint", candidate.Endpoint.String())) + log.Debug("selected storage provider candidate") + + client, err := nodeProvider.Client(candidate.ID, candidate.Endpoint) + if err != nil { + log.Error("failed to create piri node", zap.Error(err)) + return routing.StorageProviderInfo{}, nil, nil, blobcaps.AllocateOK{}, err + } + + res, inv, rcpt, err := client.Allocate(ctx, &piriclient.AllocateRequest{ + Space: space, + Digest: blob.Digest, + Size: blob.Size, + Cause: cause, + }, proofStore) + if err != nil { + log.Warn("failed to allocate blob", zap.Error(err)) + exclusions = append(exclusions, candidate.ID) + continue + } + + err = writeAgentMessage(ctx, agentStore, []ucan.Invocation{inv}, []ucan.Receipt{rcpt}) + if err != nil { + log.Error("failed to write agent message", zap.Error(err)) + exclusions = append(exclusions, candidate.ID) + continue + } + + return candidate, inv, rcpt, *res, nil + } +} + +// TODO(ash): move this into the client +func writeAgentMessage(ctx context.Context, agentStore agent.Store, invs []ucan.Invocation, rcpts []ucan.Receipt) error { + msg := container.New(container.WithInvocations(invs...), container.WithReceipts(rcpts...)) + idx := agent.Index(msg) + return agentStore.Write(ctx, msg, idx) +} + +// Generates an invocation to put the blob to the storage provider. It MAY +// return a receipt if the allocation result indicates that the provider already +// has the blob. +func genPut(blob blobcaps.Blob, allocInv ucan.Invocation, allocOK blobcaps.AllocateOK, logger *zap.Logger) (ucan.Invocation, ucan.Receipt, error) { + log := logger + log.Debug("generating put invocation") + + // Derive the principal that will provide the blob from the blob digest. + // we do this so that any actor with a blob could issue a receipt for the + // `/http/put` invocation. + blobProvider, err := deriveDID(blob.Digest) + if err != nil { + return nil, nil, err + } + + putInv, err := httpcaps.Put.Invoke( + blobProvider, + blobProvider, + &httpcaps.PutArguments{ + Body: blob, + Destination: promise.AwaitOK{Task: allocInv.Task().Link()}, + }, + invocation.WithAudience(blobProvider), + // We encode the keys for the blob provider principal that can be used + // by the client to use in order to sign a receipt. Client could + // actually derive the same principal from the blob digest like we did + // above, however by embedding the keys we make API more flexible and + // could in the future generate one-off principals instead. + invocation.WithMetadata( + datamodel.Map{ + "keys": datamodel.Map{ + "id": blobProvider.DID().String(), + "keys": datamodel.Map{ + blobProvider.DID().String(): blobProvider.Bytes(), + }, + }, + }, + ), + ) + if err != nil { + return nil, nil, fmt.Errorf("invoking %q: %w", httpcaps.PutCommand, err) + } + + var putRcpt ucan.Receipt + + // If no address was provided we have a blob in store already and we can issue + // a receipt for the `/http/put` without requiring blob to be provided. + if allocOK.Address == nil { + log.Debug("blob present on provider, issuing receipt for put") + var ok datamodel.Map + err = datamodel.Rebind(&httpcaps.PutOK{}, &ok) + if err != nil { + return nil, nil, fmt.Errorf("rebinding %q OK: %w", httpcaps.PutCommand, err) + } + putRcpt, err = receipt.Issue( + blobProvider, + putInv.Task().Link(), + result.OK[ipld.Map, ipld.Any](ok), + ) + if err != nil { + return nil, nil, fmt.Errorf("issuing %q receipt: %w", httpcaps.PutCommand, err) + } + } + + return putInv, putRcpt, nil +} + +// Derives did:key principal from (blob) multihash that can be used to +// sign ucan invocations/receipts for the the subject (blob) multihash. +func deriveDID(digest multihash.Multihash) (principal.Signer, error) { + if len(digest) < ed25519.SeedSize { + return nil, fmt.Errorf("expected []byte with length %d, got %d", ed25519.SeedSize, len(digest)) + } + seed := digest[len(digest)-ed25519.SeedSize:] + return ed25519signer.FromRaw(seed) +} + +// maybeAccept generates and possibly executes a `/blob/accept` invocation if +// the provided put receipt is non-nil and non-failure. +func maybeAccept( + ctx context.Context, + agentStore agent.Store, + blobRegistry blobregistry.Store, + nodeProvider piriclient.Provider, + providerInfo routing.StorageProviderInfo, + space ucan.Principal, + blob blobcaps.Blob, + cause ucan.Link, // original /space/blob/add task + putInv ucan.Invocation, + putRcpt ucan.Receipt, + proofStore ucan_server.ProofStore, + logger *zap.Logger, +) (ucan.Invocation, ucan.Receipt, error) { + log := logger + log.Debug("generating accept invocation") + + c, err := nodeProvider.Client(providerInfo.ID, providerInfo.Endpoint) + if err != nil { + log.Error("failed to create piri client for accept", zap.Error(err)) + return nil, nil, err + } + + accReq := piriclient.AcceptRequest{ + Space: space.DID(), + Digest: blob.Digest, + Size: blob.Size, + Put: putInv.Link(), + } + + accInv, _, _, err := c.AcceptInvocation(ctx, &accReq, proofStore, invocation.WithNoNonce()) + if err != nil { + log.Error("failed to create accept invocation", zap.Error(err)) + return nil, nil, err + } + + var accRcpt ucan.Receipt + + // If put has already succeeded, we can execute `/blob/accept` right away. + if putRcpt != nil { + _, x := result.Unwrap(putRcpt.Out()) + if x == nil { + res, inv, rcpt, err := c.Accept(ctx, &accReq, proofStore, invocation.WithNoNonce()) + if err != nil { + log.Error("failed to execute accept on piri", zap.Error(err)) + return nil, nil, err + } + log.Debug("blob accepted", zap.Stringer("site", res.Site)) + + err = writeAgentMessage(ctx, agentStore, []ucan.Invocation{inv}, []ucan.Receipt{rcpt}) + if err != nil { + log.Error("failed to write agent message for accept", zap.Error(err)) + return nil, nil, err + } + + err = blobRegistry.Register(ctx, space.DID(), blob, cause) + if err != nil { + log.Error("failed to register blob", zap.Error(err)) + return nil, nil, err + } + + accInv = inv + accRcpt = rcpt + } + } + + return accInv, accRcpt, nil +} diff --git a/pkg/service/handlers/blob_add_test.go b/pkg/service/handlers/blob_add_test.go new file mode 100644 index 0000000..0b070d9 --- /dev/null +++ b/pkg/service/handlers/blob_add_test.go @@ -0,0 +1,520 @@ +package handlers_test + +import ( + "context" + "crypto/ed25519" + "net/http/httptest" + "net/url" + "testing" + "time" + + "github.com/fil-forge/libforge/capabilities" + blobcaps "github.com/fil-forge/libforge/capabilities/blob" + httpcaps "github.com/fil-forge/libforge/capabilities/http" + "github.com/fil-forge/libforge/didmailto" + "github.com/fil-forge/ucantone/did" + edm "github.com/fil-forge/ucantone/errors/datamodel" + "github.com/fil-forge/ucantone/execution" + "github.com/fil-forge/ucantone/execution/bindexec" + "github.com/fil-forge/ucantone/ipld" + "github.com/fil-forge/ucantone/ipld/codec/dagcbor" + "github.com/fil-forge/ucantone/ipld/datamodel" + "github.com/fil-forge/ucantone/principal" + ed25519signer "github.com/fil-forge/ucantone/principal/ed25519" + "github.com/fil-forge/ucantone/principal/signer" + "github.com/fil-forge/ucantone/result" + "github.com/fil-forge/ucantone/server" + "github.com/fil-forge/ucantone/ucan/container" + "github.com/fil-forge/ucantone/ucan/delegation" + "github.com/fil-forge/ucantone/ucan/invocation" + "github.com/fil-forge/ucantone/ucan/promise" + "github.com/fil-forge/ucantone/ucan/receipt" + "github.com/fil-forge/ucantone/validator" + "github.com/multiformats/go-multihash" + "github.com/storacha/sprue/internal/testutil" + "github.com/storacha/sprue/pkg/identity" + "github.com/storacha/sprue/pkg/piriclient" + "github.com/storacha/sprue/pkg/provisioning" + "github.com/storacha/sprue/pkg/routing" + "github.com/storacha/sprue/pkg/service/handlers" + "github.com/storacha/sprue/pkg/store/agent" + agent_store "github.com/storacha/sprue/pkg/store/agent/memory" + blob_registry "github.com/storacha/sprue/pkg/store/blob_registry/memory" + consumer_store "github.com/storacha/sprue/pkg/store/consumer/memory" + metrics_store "github.com/storacha/sprue/pkg/store/metrics/memory" + spacediff_store "github.com/storacha/sprue/pkg/store/space_diff/memory" + storage_provider_store "github.com/storacha/sprue/pkg/store/storage_provider/memory" + subscription_store "github.com/storacha/sprue/pkg/store/subscription/memory" + "github.com/stretchr/testify/require" + "go.uber.org/zap" + "go.uber.org/zap/zaptest" +) + +type blobAddTestDeps struct { + handler handlers.Handler + consumerStore *consumer_store.Store + subscriptionStore *subscription_store.Store + spStore *storage_provider_store.Store + agentStore *agent_store.Store + blobReg *blob_registry.Store +} + +func newBlobAddTestDeps(t *testing.T, uploadService principal.Signer, logger *zap.Logger) *blobAddTestDeps { + t.Helper() + consumerStore := consumer_store.New() + subscriptionStore := subscription_store.New() + provisioningSvc := provisioning.NewService([]did.DID{uploadService.DID()}, consumerStore, subscriptionStore) + spStore := storage_provider_store.New() + router := routing.NewService(spStore, logger) + agentStore := agent_store.New() + blobReg := blob_registry.New( + spacediff_store.New(), + consumerStore, + metrics_store.NewSpaceStore(), + metrics_store.New(), + ) + nodeProvider := piriclient.NewProvider(uploadService, logger) + handler := handlers.NewBlobAddHandler( + &identity.Identity{Signer: uploadService}, + provisioningSvc, + router, + nodeProvider, + agentStore, + blobReg, + logger, + ) + return &blobAddTestDeps{ + handler: handler, + consumerStore: consumerStore, + subscriptionStore: subscriptionStore, + spStore: spStore, + agentStore: agentStore, + blobReg: blobReg, + } +} + +// provisionSpace adds the consumer record so provisioningSvc.ListServiceProviders +// returns the upload service for the space. +func provisionSpace(t *testing.T, deps *blobAddTestDeps, uploadService principal.Signer, space did.DID) { + t.Helper() + account := testutil.Must(didmailto.New("alice@example.com"))(t) + err := deps.consumerStore.Add( + context.Background(), + uploadService.DID(), + space, + account, + "sub-1", + testutil.RandomCID(t), + ) + require.NoError(t, err) +} + +// newMockPiriServer stands up a UCAN HTTP server that handles /blob/allocate & +// /blob/accept by returning the canned responses. Wraps the upload service's +// did:web identity so signatures verify against the underlying did:key. +func newMockPiriServer( + t *testing.T, + storageProvider principal.Signer, + uploadService principal.Signer, + allocateOK *blobcaps.AllocateOK, + acceptOK *blobcaps.AcceptOK, +) *httptest.Server { + t.Helper() + + resolveDIDKey := func(ctx context.Context, d did.DID) ([]did.DID, error) { + if d == uploadService.DID() { + if w, ok := uploadService.(signer.Unwrapper); ok { + return []did.DID{w.Unwrap().DID()}, nil + } + } + return validator.FailDIDKeyResolution(ctx, d) + } + + srv := server.NewHTTP( + storageProvider, + server.WithValidationOptions(validator.WithDIDResolver(resolveDIDKey)), + ) + + srv.Handle(blobcaps.Allocate, bindexec.NewHandler(func( + req *bindexec.Request[*blobcaps.AllocateArguments], + res *bindexec.Response[*blobcaps.AllocateOK], + ) error { + return res.SetSuccess(allocateOK) + })) + + srv.Handle(blobcaps.Accept, bindexec.NewHandler(func( + req *bindexec.Request[*blobcaps.AcceptArguments], + res *bindexec.Response[*blobcaps.AcceptOK], + ) error { + return res.SetSuccess(acceptOK) + })) + + httpSrv := httptest.NewServer(srv) + t.Cleanup(httpSrv.Close) + return httpSrv +} + +func TestBlobAddHandler(t *testing.T) { + logger := zaptest.NewLogger(t) + ctx := t.Context() + + uploadService := testutil.WebService + + t.Run("no providers for space", func(t *testing.T) { + deps := newBlobAddTestDeps(t, uploadService, logger) + + space := testutil.RandomSigner(t) + args := blobcaps.AddArguments{ + Blob: blobcaps.Blob{Digest: testutil.RandomMultihash(t), Size: 1024}, + } + + inv, err := blobcaps.Add.Invoke( + testutil.Alice, + space, + &args, + invocation.WithAudience(uploadService), + ) + require.NoError(t, err) + + req := execution.NewRequest(ctx, inv) + res, err := execution.NewResponse(req.Invocation().Task().Link(), execution.WithSigner(uploadService)) + require.NoError(t, err) + + err = deps.handler.Handler(req, res) + require.NoError(t, err) + + _, fail := result.Unwrap(res.Receipt().Out()) + require.NotNil(t, fail) + + model := edm.ErrorModel{} + err = datamodel.Rebind(datamodel.NewAny(fail), &model) + require.NoError(t, err) + require.Equal(t, handlers.InsufficientStorageErrorName, model.Name()) + }) + + t.Run("no candidates available", func(t *testing.T) { + deps := newBlobAddTestDeps(t, uploadService, logger) + + space := testutil.RandomSigner(t) + provisionSpace(t, deps, uploadService, space.DID()) + + // No storage providers in spStore — the router will return ErrCandidateUnavailable. + args := blobcaps.AddArguments{ + Blob: blobcaps.Blob{Digest: testutil.RandomMultihash(t), Size: 1024}, + } + + inv, err := blobcaps.Add.Invoke( + testutil.Alice, + space, + &args, + invocation.WithAudience(uploadService), + ) + require.NoError(t, err) + + req := execution.NewRequest(ctx, inv) + res, err := execution.NewResponse(req.Invocation().Task().Link(), execution.WithSigner(uploadService)) + require.NoError(t, err) + + err = deps.handler.Handler(req, res) + require.NoError(t, err) + + _, fail := result.Unwrap(res.Receipt().Out()) + require.NotNil(t, fail) + + model := edm.ErrorModel{} + err = datamodel.Rebind(datamodel.NewAny(fail), &model) + require.NoError(t, err) + require.Equal(t, routing.CandidateUnavailableErrorName, model.Name()) + }) + + t.Run("zero weight providers returns candidate unavailable", func(t *testing.T) { + deps := newBlobAddTestDeps(t, uploadService, logger) + + space := testutil.RandomSigner(t) + provisionSpace(t, deps, uploadService, space.DID()) + + // Register a storage provider with weight 0 — it'll be filtered out. + storageProvider := testutil.RandomSigner(t) + endpoint := testutil.Must(url.Parse("https://piri.example.com"))(t) + err := deps.spStore.Put(ctx, storageProvider.DID(), *endpoint, 0, nil) + require.NoError(t, err) + + args := blobcaps.AddArguments{ + Blob: blobcaps.Blob{Digest: testutil.RandomMultihash(t), Size: 1024}, + } + + inv, err := blobcaps.Add.Invoke( + testutil.Alice, + space, + &args, + invocation.WithAudience(uploadService), + ) + require.NoError(t, err) + + req := execution.NewRequest(ctx, inv) + res, err := execution.NewResponse(req.Invocation().Task().Link(), execution.WithSigner(uploadService)) + require.NoError(t, err) + + err = deps.handler.Handler(req, res) + require.NoError(t, err) + + _, fail := result.Unwrap(res.Receipt().Out()) + require.NotNil(t, fail) + + model := edm.ErrorModel{} + err = datamodel.Rebind(datamodel.NewAny(fail), &model) + require.NoError(t, err) + require.Equal(t, routing.CandidateUnavailableErrorName, model.Name()) + }) + + t.Run("successful allocation with address", func(t *testing.T) { + deps := newBlobAddTestDeps(t, uploadService, logger) + + space := testutil.RandomSigner(t) + provisionSpace(t, deps, uploadService, space.DID()) + + storageProvider := testutil.RandomSigner(t) + putURL := testutil.Must(url.Parse("https://storage.example.com/put"))(t) + allocateOK := &blobcaps.AllocateOK{ + Size: 1024, + Address: &blobcaps.BlobAddress{ + URL: capabilities.CborURL(*putURL), + Headers: map[string]string{}, + Expires: time.Now().Add(time.Hour).Unix(), + }, + } + // Accept handler is registered but should not be invoked when an Address is + // returned — the put receipt isn't issued, so maybeAccept skips Accept. + acceptOK := &blobcaps.AcceptOK{Site: testutil.RandomCID(t)} + + piriSrv := newMockPiriServer(t, storageProvider, uploadService, allocateOK, acceptOK) + piriURL := testutil.Must(url.Parse(piriSrv.URL))(t) + + err := deps.spStore.Put(ctx, storageProvider.DID(), *piriURL, 100, nil) + require.NoError(t, err) + + args := blobcaps.AddArguments{ + Blob: blobcaps.Blob{Digest: testutil.RandomMultihash(t), Size: 1024}, + } + + inv, err := blobcaps.Add.Invoke( + testutil.Alice, + space, + &args, + invocation.WithAudience(uploadService), + ) + require.NoError(t, err) + + // Authorize the upload service to invoke /blob/allocate and /blob/accept + // on the space. This is the proof chain the upload service forwards to the + // storage provider. + allocProof := testutil.Must(delegation.Delegate(space, uploadService, space, blobcaps.AllocateCommand))(t) + acceptProof := testutil.Must(delegation.Delegate(space, uploadService, space, blobcaps.AcceptCommand))(t) + + req := execution.NewRequest(ctx, inv, execution.WithDelegations(allocProof, acceptProof)) + res, err := execution.NewResponse(req.Invocation().Task().Link(), execution.WithSigner(uploadService)) + require.NoError(t, err) + + err = deps.handler.Handler(req, res) + require.NoError(t, err) + + o, fail := result.Unwrap(res.Receipt().Out()) + require.Nil(t, fail) + require.NotNil(t, o) + + // Response metadata should carry the allocate, put, and accept invocations. + require.NotNil(t, res.Metadata()) + require.NotEmpty(t, res.Metadata().Invocations()) + }) + + t.Run("successful allocation blob already stored", func(t *testing.T) { + deps := newBlobAddTestDeps(t, uploadService, logger) + + space := testutil.RandomSigner(t) + provisionSpace(t, deps, uploadService, space.DID()) + + storageProvider := testutil.RandomSigner(t) + // No address signals the blob is already on the provider — the handler + // then issues the put receipt itself and proceeds to Accept on piri. + allocateOK := &blobcaps.AllocateOK{Size: 1024, Address: nil} + acceptOK := &blobcaps.AcceptOK{Site: testutil.RandomCID(t)} + + piriSrv := newMockPiriServer(t, storageProvider, uploadService, allocateOK, acceptOK) + piriURL := testutil.Must(url.Parse(piriSrv.URL))(t) + + err := deps.spStore.Put(ctx, storageProvider.DID(), *piriURL, 100, nil) + require.NoError(t, err) + + args := blobcaps.AddArguments{ + Blob: blobcaps.Blob{Digest: testutil.RandomMultihash(t), Size: 1024}, + } + + inv, err := blobcaps.Add.Invoke( + testutil.Alice, + space, + &args, + invocation.WithAudience(uploadService), + ) + require.NoError(t, err) + + allocProof := testutil.Must(delegation.Delegate(space, uploadService, space, blobcaps.AllocateCommand))(t) + acceptProof := testutil.Must(delegation.Delegate(space, uploadService, space, blobcaps.AcceptCommand))(t) + + req := execution.NewRequest(ctx, inv, execution.WithDelegations(allocProof, acceptProof)) + res, err := execution.NewResponse(req.Invocation().Task().Link(), execution.WithSigner(uploadService)) + require.NoError(t, err) + + err = deps.handler.Handler(req, res) + require.NoError(t, err) + + o, fail := result.Unwrap(res.Receipt().Out()) + require.Nil(t, fail) + require.NotNil(t, o) + + // Both invocations and receipts should be in the metadata since accept ran. + require.NotNil(t, res.Metadata()) + require.NotEmpty(t, res.Metadata().Invocations()) + require.NotEmpty(t, res.Metadata().Receipts()) + }) + + t.Run("blob already registered in space", func(t *testing.T) { + deps := newBlobAddTestDeps(t, uploadService, logger) + + space := testutil.RandomSigner(t) + provisionSpace(t, deps, uploadService, space.DID()) + + storageProvider := testutil.RandomSigner(t) + digest := testutil.RandomMultihash(t) + blob := blobcaps.Blob{Digest: digest, Size: 1024} + + // Build the chain that the handler will walk back through: + // addRcpt → accInv/accRcpt → putInv/putRcpt → allocInv/allocRcpt + blobProvider := deriveBlobProvider(t, digest) + + // /blob/allocate + allocInv := testutil.Must(blobcaps.Allocate.Invoke( + uploadService, + space, + &blobcaps.AllocateArguments{Blob: blob, Cause: testutil.RandomCID(t)}, + invocation.WithAudience(storageProvider), + ))(t) + allocOK := mustRebindMap(t, &blobcaps.AllocateOK{Size: blob.Size}) + allocRcpt := testutil.Must(receipt.Issue( + storageProvider, + allocInv.Task().Link(), + result.OK[ipld.Map, ipld.Any](allocOK), + ))(t) + + // /http/put — issued by the principal derived from the blob digest. + putInv := testutil.Must(httpcaps.Put.Invoke( + blobProvider, + blobProvider, + &httpcaps.PutArguments{ + Body: blob, + Destination: promise.AwaitOK{Task: allocInv.Task().Link()}, + }, + invocation.WithAudience(blobProvider), + ))(t) + putOK := mustRebindMap(t, &httpcaps.PutOK{}) + putRcpt := testutil.Must(receipt.Issue( + blobProvider, + putInv.Task().Link(), + result.OK[ipld.Map, ipld.Any](putOK), + ))(t) + + // /blob/accept + accInv := testutil.Must(blobcaps.Accept.Invoke( + uploadService, + space, + &blobcaps.AcceptArguments{ + Blob: blob, + Put: promise.AwaitOK{Task: putInv.Task().Link()}, + }, + invocation.WithAudience(storageProvider), + ))(t) + accOK := mustRebindMap(t, &blobcaps.AcceptOK{Site: testutil.RandomCID(t)}) + accRcpt := testutil.Must(receipt.Issue( + storageProvider, + accInv.Task().Link(), + result.OK[ipld.Map, ipld.Any](accOK), + ))(t) + + // The original /space/blob/add invocation and receipt — its receipt's + // task CID is what gets stored in the registry as the cause. + prevAddInv := testutil.Must(blobcaps.Add.Invoke( + testutil.Alice, + space, + &blobcaps.AddArguments{Blob: blob}, + invocation.WithAudience(uploadService), + ))(t) + addOK := mustRebindMap(t, &blobcaps.AddOK{ + Site: promise.AwaitOK{Task: accInv.Task().Link()}, + }) + prevAddRcpt := testutil.Must(receipt.Issue( + uploadService, + prevAddInv.Task().Link(), + result.OK[ipld.Map, ipld.Any](addOK), + ))(t) + + // Persist the chain in the agent store and register the blob with cause + // pointing at the prior /blob/add invocation's task CID. + msg := container.New( + container.WithInvocations(allocInv, putInv, accInv, prevAddInv), + container.WithReceipts(allocRcpt, putRcpt, accRcpt, prevAddRcpt), + ) + require.NoError(t, deps.agentStore.Write(ctx, msg, agent.Index(msg))) + require.NoError(t, deps.blobReg.Register(ctx, space.DID(), blob, prevAddInv.Task().Link())) + + // Re-invoke /blob/add for the same blob/space — the handler should hit + // the already-registered short-circuit, walk the chain, and return the + // stored AddOK without contacting any storage provider. + inv := testutil.Must(blobcaps.Add.Invoke( + testutil.Alice, + space, + &blobcaps.AddArguments{Blob: blob}, + invocation.WithAudience(uploadService), + ))(t) + + req := execution.NewRequest(ctx, inv) + res, err := execution.NewResponse(req.Invocation().Task().Link(), execution.WithSigner(uploadService)) + require.NoError(t, err) + + err = deps.handler.Handler(req, res) + require.NoError(t, err) + + o, fail := result.Unwrap(res.Receipt().Out()) + require.Nil(t, fail) + require.NotNil(t, o) + + // The returned AddOK should match the one from the prior receipt. + gotAddOK := blobcaps.AddOK{} + require.NoError(t, datamodel.Rebind(datamodel.NewAny(o), &gotAddOK)) + require.Equal(t, accInv.Task().Link(), gotAddOK.Site.Task) + + // Response metadata should carry all three invocations and all three + // receipts from the prior chain. + require.NotNil(t, res.Metadata()) + require.Len(t, res.Metadata().Invocations(), 3) + require.Len(t, res.Metadata().Receipts(), 3) + }) +} + +// deriveBlobProvider mirrors the production handler's logic for deriving a +// signer from a blob's digest, used to sign /http/put invocations and receipts. +func deriveBlobProvider(t *testing.T, digest multihash.Multihash) principal.Signer { + t.Helper() + require.GreaterOrEqual(t, len(digest), ed25519.SeedSize) + seed := digest[len(digest)-ed25519.SeedSize:] + s, err := ed25519signer.FromRaw(seed) + require.NoError(t, err) + return s +} + +// mustRebindMap rebinds a model into a datamodel.Map for use as receipt output — +// receipt.Issue can't marshal arbitrary struct pointers via ipld.Any. +func mustRebindMap(t *testing.T, model dagcbor.Marshaler) datamodel.Map { + t.Helper() + m := datamodel.Map{} + require.NoError(t, datamodel.Rebind(model, &m)) + return m +} diff --git a/pkg/service/handlers/blob_list.go b/pkg/service/handlers/blob_list.go new file mode 100644 index 0000000..fecb5a8 --- /dev/null +++ b/pkg/service/handlers/blob_list.go @@ -0,0 +1,55 @@ +package handlers + +import ( + "fmt" + + blobcaps "github.com/fil-forge/libforge/capabilities/blob" + "github.com/fil-forge/ucantone/execution/bindexec" + blobregistry "github.com/storacha/sprue/pkg/store/blob_registry" + "go.uber.org/zap" +) + +func NewBlobListHandler(blobRegistry blobregistry.Store, logger *zap.Logger) Handler { + log := logger.With(zap.String("handler", blobcaps.ListCommand)) + return Handler{ + Capability: blobcaps.List, + Handler: bindexec.NewHandler(func( + req *bindexec.Request[*blobcaps.ListArguments], + res *bindexec.Response[*blobcaps.ListOK], + ) error { + args := req.Task().BindArguments() + space := req.Invocation().Subject() + log := log.With(zap.Stringer("space", space.DID())) + + var opts []blobregistry.ListOption + if args.Size != nil { + log = log.With(zap.Int64("size", *args.Size)) + opts = append(opts, blobregistry.WithListLimit(int(*args.Size))) + } + if args.Cursor != nil { + log = log.With(zap.String("cursor", *args.Cursor)) + opts = append(opts, blobregistry.WithListCursor(*args.Cursor)) + } + log.Debug("listing blobs") + + page, err := blobRegistry.List(req.Context(), space.DID(), opts...) + if err != nil { + log.Error("failed to list blobs", zap.Error(err)) + return fmt.Errorf("listing blobs: %w", err) + } + + results := make([]blobcaps.ListBlobItem, 0, len(page.Results)) + for _, r := range page.Results { + results = append(results, blobcaps.ListBlobItem{ + Blob: r.Blob, + InsertedAt: r.InsertedAt.Unix(), + }) + } + + return res.SetSuccess(&blobcaps.ListOK{ + Cursor: page.Cursor, + Results: results, + }) + }), + } +} diff --git a/pkg/service/handlers/blob_list_test.go b/pkg/service/handlers/blob_list_test.go new file mode 100644 index 0000000..56c9113 --- /dev/null +++ b/pkg/service/handlers/blob_list_test.go @@ -0,0 +1,203 @@ +package handlers_test + +import ( + "context" + "testing" + + blobcaps "github.com/fil-forge/libforge/capabilities/blob" + "github.com/fil-forge/libforge/didmailto" + "github.com/fil-forge/ucantone/execution" + "github.com/fil-forge/ucantone/ipld/datamodel" + "github.com/fil-forge/ucantone/principal" + "github.com/fil-forge/ucantone/result" + "github.com/fil-forge/ucantone/ucan/invocation" + "github.com/storacha/sprue/internal/testutil" + "github.com/storacha/sprue/pkg/service/handlers" + blob_registry "github.com/storacha/sprue/pkg/store/blob_registry/memory" + consumer_store "github.com/storacha/sprue/pkg/store/consumer/memory" + metrics_store "github.com/storacha/sprue/pkg/store/metrics/memory" + spacediff_store "github.com/storacha/sprue/pkg/store/space_diff/memory" + "github.com/stretchr/testify/require" + "go.uber.org/zap/zaptest" +) + +func newBlobRegistry(t *testing.T) (*blob_registry.Store, *consumer_store.Store) { + t.Helper() + consumerStore := consumer_store.New() + return blob_registry.New( + spacediff_store.New(), + consumerStore, + metrics_store.NewSpaceStore(), + metrics_store.New(), + ), consumerStore +} + +// invokeBlobList builds the invocation/request/response trio used by every +// subtest below. +func invokeBlobList( + t *testing.T, + ctx context.Context, + agent principal.Signer, + uploadService principal.Signer, + space principal.Signer, + args *blobcaps.ListArguments, +) (execution.Request, *execution.ExecResponse) { + t.Helper() + inv, err := blobcaps.List.Invoke( + agent, + space, + args, + invocation.WithAudience(uploadService), + ) + require.NoError(t, err) + req := execution.NewRequest(ctx, inv) + res, err := execution.NewResponse(req.Invocation().Task().Link(), execution.WithSigner(uploadService)) + require.NoError(t, err) + return req, res +} + +func TestBlobListHandler(t *testing.T) { + logger := zaptest.NewLogger(t) + ctx := t.Context() + + uploadService := testutil.WebService + alice := testutil.Alice + aliceAccount := testutil.Must(didmailto.New("alice@example.com"))(t) + + t.Run("empty list", func(t *testing.T) { + blobReg, _ := newBlobRegistry(t) + handler := handlers.NewBlobListHandler(blobReg, logger) + + space := testutil.RandomSigner(t) + + req, res := invokeBlobList(t, ctx, alice, uploadService, space, &blobcaps.ListArguments{}) + + err := handler.Handler(req, res) + require.NoError(t, err) + + o, fail := result.Unwrap(res.Receipt().Out()) + require.Nil(t, fail) + require.NotNil(t, o) + + ok := blobcaps.ListOK{} + require.NoError(t, datamodel.Rebind(datamodel.NewAny(o), &ok)) + require.Empty(t, ok.Results) + }) + + t.Run("lists blobs", func(t *testing.T) { + blobReg, consumerStore := newBlobRegistry(t) + handler := handlers.NewBlobListHandler(blobReg, logger) + + space := testutil.RandomSigner(t) + require.NoError(t, consumerStore.Add(ctx, uploadService.DID(), space.DID(), aliceAccount, "sub-1", testutil.RandomCID(t))) + + blob1 := blobcaps.Blob{Digest: testutil.RandomMultihash(t), Size: 100} + blob2 := blobcaps.Blob{Digest: testutil.RandomMultihash(t), Size: 200} + require.NoError(t, blobReg.Register(ctx, space.DID(), blob1, testutil.RandomCID(t))) + require.NoError(t, blobReg.Register(ctx, space.DID(), blob2, testutil.RandomCID(t))) + + req, res := invokeBlobList(t, ctx, alice, uploadService, space, &blobcaps.ListArguments{}) + + err := handler.Handler(req, res) + require.NoError(t, err) + + o, fail := result.Unwrap(res.Receipt().Out()) + require.Nil(t, fail) + ok := blobcaps.ListOK{} + require.NoError(t, datamodel.Rebind(datamodel.NewAny(o), &ok)) + require.Len(t, ok.Results, 2) + }) + + t.Run("with size limit", func(t *testing.T) { + blobReg, consumerStore := newBlobRegistry(t) + handler := handlers.NewBlobListHandler(blobReg, logger) + + space := testutil.RandomSigner(t) + require.NoError(t, consumerStore.Add(ctx, uploadService.DID(), space.DID(), aliceAccount, "sub-1", testutil.RandomCID(t))) + + for i := range 3 { + require.NoError(t, blobReg.Register( + ctx, space.DID(), + blobcaps.Blob{Digest: testutil.RandomMultihash(t), Size: uint64(i + 1)}, + testutil.RandomCID(t), + )) + } + + size := int64(2) + req, res := invokeBlobList(t, ctx, alice, uploadService, space, &blobcaps.ListArguments{Size: &size}) + + err := handler.Handler(req, res) + require.NoError(t, err) + + o, fail := result.Unwrap(res.Receipt().Out()) + require.Nil(t, fail) + ok := blobcaps.ListOK{} + require.NoError(t, datamodel.Rebind(datamodel.NewAny(o), &ok)) + require.Len(t, ok.Results, 2) + require.NotNil(t, ok.Cursor) + }) + + t.Run("with cursor pagination", func(t *testing.T) { + blobReg, consumerStore := newBlobRegistry(t) + handler := handlers.NewBlobListHandler(blobReg, logger) + + space := testutil.RandomSigner(t) + require.NoError(t, consumerStore.Add(ctx, uploadService.DID(), space.DID(), aliceAccount, "sub-1", testutil.RandomCID(t))) + + for i := range 3 { + require.NoError(t, blobReg.Register( + ctx, space.DID(), + blobcaps.Blob{Digest: testutil.RandomMultihash(t), Size: uint64(i + 1)}, + testutil.RandomCID(t), + )) + } + + size := int64(1) + req1, res1 := invokeBlobList(t, ctx, alice, uploadService, space, &blobcaps.ListArguments{Size: &size}) + require.NoError(t, handler.Handler(req1, res1)) + + o1, fail := result.Unwrap(res1.Receipt().Out()) + require.Nil(t, fail) + ok1 := blobcaps.ListOK{} + require.NoError(t, datamodel.Rebind(datamodel.NewAny(o1), &ok1)) + require.Len(t, ok1.Results, 1) + require.NotNil(t, ok1.Cursor) + + // Second page using cursor. + cursor := *ok1.Cursor + req2, res2 := invokeBlobList(t, ctx, alice, uploadService, space, &blobcaps.ListArguments{Cursor: &cursor, Size: &size}) + require.NoError(t, handler.Handler(req2, res2)) + + o2, fail := result.Unwrap(res2.Receipt().Out()) + require.Nil(t, fail) + ok2 := blobcaps.ListOK{} + require.NoError(t, datamodel.Rebind(datamodel.NewAny(o2), &ok2)) + require.Len(t, ok2.Results, 1) + require.NotEqual(t, ok1.Results[0].Blob.Digest.HexString(), ok2.Results[0].Blob.Digest.HexString()) + }) + + t.Run("does not list blobs from other spaces", func(t *testing.T) { + blobReg, consumerStore := newBlobRegistry(t) + handler := handlers.NewBlobListHandler(blobReg, logger) + + space1 := testutil.RandomSigner(t) + space2 := testutil.RandomSigner(t) + require.NoError(t, consumerStore.Add(ctx, uploadService.DID(), space1.DID(), aliceAccount, "sub-1", testutil.RandomCID(t))) + + require.NoError(t, blobReg.Register( + ctx, space1.DID(), + blobcaps.Blob{Digest: testutil.RandomMultihash(t), Size: 100}, + testutil.RandomCID(t), + )) + + // Query space2 — should be empty. + req, res := invokeBlobList(t, ctx, alice, uploadService, space2, &blobcaps.ListArguments{}) + require.NoError(t, handler.Handler(req, res)) + + o, fail := result.Unwrap(res.Receipt().Out()) + require.Nil(t, fail) + ok := blobcaps.ListOK{} + require.NoError(t, datamodel.Rebind(datamodel.NewAny(o), &ok)) + require.Empty(t, ok.Results) + }) +} diff --git a/pkg/service/handlers/blob_replicate.go b/pkg/service/handlers/blob_replicate.go new file mode 100644 index 0000000..bafee33 --- /dev/null +++ b/pkg/service/handlers/blob_replicate.go @@ -0,0 +1,477 @@ +package handlers + +// import ( +// "bytes" +// "context" +// "fmt" +// "slices" + +// "go.uber.org/zap" + +// "github.com/fil-forge/ucantone/did" +// "github.com/fil-forge/ucantone/errors" +// "github.com/multiformats/go-multihash" +// "github.com/storacha/go-libstoracha/capabilities/assert" +// blobreplicacap "github.com/storacha/go-libstoracha/capabilities/blob/replica" +// spaceblobcap "github.com/storacha/go-libstoracha/capabilities/space/blob" +// "github.com/storacha/go-libstoracha/capabilities/types" +// "github.com/storacha/go-libstoracha/digestutil" +// "github.com/storacha/go-ucanto/core/dag/blockstore" +// "github.com/storacha/go-ucanto/core/delegation" +// "github.com/storacha/go-ucanto/core/invocation" +// "github.com/storacha/go-ucanto/core/ipld" +// "github.com/storacha/go-ucanto/core/receipt" +// "github.com/storacha/go-ucanto/core/receipt/fx" +// "github.com/storacha/go-ucanto/core/result" +// "github.com/storacha/go-ucanto/core/result/failure" +// fdm "github.com/storacha/go-ucanto/core/result/failure/datamodel" +// "github.com/storacha/go-ucanto/server" +// "github.com/storacha/go-ucanto/ucan" +// "github.com/storacha/go-ucanto/validator" +// "github.com/storacha/sprue/internal/config" +// "github.com/storacha/sprue/pkg/identity" +// "github.com/storacha/sprue/pkg/internal/ipldutil" +// "github.com/storacha/sprue/pkg/piriclient" +// "github.com/storacha/sprue/pkg/routing" +// "github.com/storacha/sprue/pkg/store/agent" +// blobregistry "github.com/storacha/sprue/pkg/store/blob_registry" +// "github.com/storacha/sprue/pkg/store/replica" +// ) + +// const ( +// // Too many or too few replicas were instructed. +// ReplicationCountRangeErrorName = "ReplicationCountRangeError" +// // There are not enough replication nodes available to replicate the data. +// ReplicationCandidateUnavailableErrorName = "ReplicationCandidateUnavailable" +// // Blob to replicate was not found in the space. +// ReplicationSourceNotFoundErrorName = "ReplicationSourceNotFound" +// // The location commitment was invalid in some way. For example, it has +// // expired, is revoked, had a signature that did not verify or referenced a +// // blob that was not requested to be replicated. +// InvalidReplicationSiteErrorName = "InvalidReplicationSite" +// ) + +// var ( +// ErrReplicationSourceNotFound = errors.New(ReplicationSourceNotFoundErrorName, "blob to replicate was not found in the space") +// ErrReplicationCandidateUnavailable = errors.New(ReplicationCandidateUnavailableErrorName, "no replication candidates available") +// ) + +// // WithSpaceBlobReplicateMethod registers the space/blob/replicate handler. +// func WithSpaceBlobReplicateMethod( +// cfg config.DeploymentConfig, +// id *identity.Identity, +// router *routing.Service, +// blobRegistry blobregistry.Store, +// replicaStore replica.Store, +// agentStore agent.Store, +// storageNode piriclient.Provider, +// logger *zap.Logger, +// ) server.Option { +// return server.WithServiceMethod( +// spaceblobcap.ReplicateAbility, +// server.Provide( +// spaceblobcap.Replicate, +// SpaceBlobReplicateHandler(cfg, id, router, blobRegistry, replicaStore, agentStore, storageNode, logger), +// ), +// ) +// } + +// func BlobReplicateHandler( +// cfg config.DeploymentConfig, +// id *identity.Identity, +// router *routing.Service, +// blobRegistry blobregistry.Store, +// replicaStore replica.Store, +// agentStore agent.Store, +// storageNode piriclient.Provider, +// logger *zap.Logger, +// ) server.HandlerFunc[spaceblobcap.ReplicateCaveats, spaceblobcap.ReplicateOk, failure.IPLDBuilderFailure] { +// log := logger.With(zap.String("handler", spaceblobcap.ReplicateAbility)) +// return func(ctx context.Context, +// cap ucan.Capability[spaceblobcap.ReplicateCaveats], +// inv invocation.Invocation, +// iCtx server.InvocationContext, +// ) (result.Result[spaceblobcap.ReplicateOk, failure.IPLDBuilderFailure], fx.Effects, error) { +// space, err := did.Parse(cap.With()) +// if err != nil { +// return result.Error[spaceblobcap.ReplicateOk, failure.IPLDBuilderFailure]( +// errors.New(InvalidSpaceErrorName, "invalid space DID: %v", err), +// ), nil, nil +// } +// blob := cap.Nb().Blob +// replicas := cap.Nb().Replicas + +// log := log.With( +// zap.Stringer("space", space), +// zap.Dict( +// "blob", +// zap.String("digest", digestutil.Format(blob.Digest)), +// zap.Uint64("size", blob.Size), +// ), +// zap.Uint("replicas", replicas), +// ) +// log.Debug("replicating blob") + +// if replicas > cfg.MaxReplicas { +// log.Warn("replication count out of range") +// return result.Error[spaceblobcap.ReplicateOk, failure.IPLDBuilderFailure]( +// errors.New(ReplicationCountRangeErrorName, "requested number of replicas is greater than maximum: %d", cfg.MaxReplicas), +// ), nil, nil +// } + +// _, err = blobRegistry.Get(ctx, space, blob.Digest) +// if err != nil { +// if errors.Is(err, blobregistry.ErrEntryNotFound) { +// log.Warn("replication source not found in space") +// return result.Error[spaceblobcap.ReplicateOk, failure.IPLDBuilderFailure]( +// ErrReplicationSourceNotFound, +// ), nil, nil +// } +// log.Error("failed to get blob registration") +// return nil, nil, fmt.Errorf("getting blob registration: %w", err) +// } + +// // check if we have any active replications +// records, err := replicaStore.List(ctx, space, blob.Digest) +// if err != nil { +// log.Error("failed to list replicas", zap.Error(err)) +// return nil, nil, fmt.Errorf("listing replicas: %w", err) +// } + +// // TODO: handle the case where a receipt was not received and the replica +// // still exists in "allocated", but has actually timed out/failed. + +// var activeReplicas []replica.Record +// var failedReplicas []replica.Record + +// var allocTasks []invocation.Invocation +// var allocReceipts []receipt.AnyReceipt +// var transferTasks []invocation.Invocation +// var transferReceipts []receipt.AnyReceipt + +// for _, r := range records { +// if r.Status == replica.Failed { +// failedReplicas = append(failedReplicas, r) +// } else { +// detail, err := replicaFxDetail(ctx, agentStore, r, logger) +// if err != nil { +// log.Error("failed to get replica details", zap.Error(err)) +// return nil, nil, fmt.Errorf("getting replica details: %w", err) +// } +// activeReplicas = append(activeReplicas, r) +// allocTasks = append(allocTasks, detail.allocate.invocation) +// allocReceipts = append(allocReceipts, detail.allocate.receipt) +// if detail.transfer != nil { +// transferTasks = append(transferTasks, detail.transfer.invocation) +// if detail.transfer.receipt != nil { +// transferReceipts = append(transferReceipts, detail.transfer.receipt) +// } +// } +// } +// } + +// // Note: We +1 below to include the source blob, which is not recorded in +// // the replicas table. +// newReplicasCount := int(replicas) - (len(activeReplicas) + 1) + +// // TODO: support reducing the number of replicas +// if newReplicasCount < 0 { +// return result.Error[spaceblobcap.ReplicateOk, failure.IPLDBuilderFailure]( +// errors.New(ReplicationCountRangeErrorName, "reducing replica count not implemented"), +// ), nil, nil +// } + +// // lets allocate some replicas! +// if newReplicasCount > 0 { +// blocks, err := blockstore.NewBlockReader(blockstore.WithBlocksIterator(inv.Blocks())) +// if err != nil { +// return nil, nil, fmt.Errorf("creating block reader: %w", err) +// } +// site := cap.Nb().Site +// lComm, location, err := extractLocationCommitment(space, blob.Digest, site, blocks) +// if err != nil { +// log.Warn("failed to extract location commitment", zap.Stringer("site", site), zap.Error(err)) +// return result.Error[spaceblobcap.ReplicateOk, failure.IPLDBuilderFailure]( +// errors.New(InvalidReplicationSiteErrorName, "invalid location commitment: %s", err.Error()), +// ), nil, nil +// } +// _, err = validator.Claim( +// ctx, +// assert.Location, +// []delegation.Proof{delegation.FromDelegation(lComm)}, +// validator.NewClaimContext( +// id.Signer.Verifier(), +// iCtx.CanIssue, +// iCtx.ValidateAuthorization, +// iCtx.ResolveProof, +// iCtx.ParsePrincipal, +// iCtx.ResolveDIDKey, +// iCtx.ValidateTimeBounds, +// iCtx.AuthorityProofs()..., +// ), +// ) +// if err != nil { +// log.Warn("failed to authorize location commitment", zap.Stringer("site", site), zap.Error(err)) +// return result.Error[spaceblobcap.ReplicateOk, failure.IPLDBuilderFailure]( +// errors.New(InvalidReplicationSiteErrorName, "unauthorized location commitment: %s", err.Error()), +// ), nil, nil +// } + +// urls := make([]string, 0, len(location.Location)) +// for _, url := range location.Location { +// urls = append(urls, url.String()) +// } +// siteLogFields := []zap.Field{zap.Stringer("root", site), zap.Strings("locations", urls)} +// if location.Range != nil { +// siteLogFields = append(siteLogFields, zap.Uint64("offset", location.Range.Offset)) +// if location.Range.Length != nil { +// siteLogFields = append(siteLogFields, zap.Uint64("length", *location.Range.Length)) +// } +// } +// log = log.With(zap.Dict("site", siteLogFields...)) +// log.Debug("allocating space to replicate blob") + +// // do not include any nodes where we already have replications +// var exclude []ucan.Principal +// for _, r := range activeReplicas { +// exclude = append(exclude, r.Provider) +// } + +// for range newReplicasCount { +// for { +// candidate, err := router.SelectReplicationProvider(ctx, lComm.Issuer(), blob, routing.WithExclusions(exclude...)) +// if err != nil { +// if errors.Is(err, routing.ErrCandidateUnavailable) { +// log.Warn("no replication candidates available") +// return result.Error[spaceblobcap.ReplicateOk, failure.IPLDBuilderFailure]( +// ErrReplicationCandidateUnavailable, +// ), nil, nil +// } +// log.Error("failed to select replication provider", zap.Error(err)) +// return nil, nil, fmt.Errorf("selecting replication provider: %w", err) +// } + +// log.Debug("selected replication provider", zap.Stringer("provider", candidate.ID.DID())) +// client, err := storageNode.Client(candidate.ID, candidate.Endpoint) +// if err != nil { +// log.Error("failed to create storage node client", zap.Error(err)) +// return nil, nil, fmt.Errorf("creating storage node client: %w", err) +// } + +// allocRes, allocInv, allocRcpt, err := client.ReplicaAllocate(ctx, &piriclient.ReplicaAllocateRequest{ +// Space: space, +// Digest: blob.Digest, +// Size: blob.Size, +// Site: lComm, +// Cause: inv.Link(), +// }, delegationFetcher{proof: candidate.Proof}) +// if err != nil { +// log.Warn("failed to allocate replica", zap.Error(err)) +// exclude = append(exclude, candidate.ID) +// continue +// } + +// // record the invocation and the receipt, so we can retrieve it later +// // when we get a blob/replica/transfer receipt in ucan/conclude +// err = writeAgentMessage(ctx, agentStore, []invocation.Invocation{allocInv}, []receipt.AnyReceipt{allocRcpt}) +// if err != nil { +// log.Error("failed to write agent message", zap.Error(err)) +// return nil, nil, fmt.Errorf("writing agent message: %w", err) +// } + +// // write a replication record to the store +// firstTimeReplica := !slices.ContainsFunc(failedReplicas, func(r replica.Record) bool { +// return r.Provider.DID() == candidate.ID.DID() +// }) +// status := result.MatchResultR1( +// allocRcpt.Out(), +// func(o ipld.Node) replica.ReplicationStatus { +// return replica.Allocated +// }, +// func(x ipld.Node) replica.ReplicationStatus { +// return replica.Failed +// }, +// ) +// cause, err := ipldutil.ToCID(allocInv.Link()) +// if err != nil { +// return nil, nil, err +// } +// if firstTimeReplica { +// err = replicaStore.Add(ctx, space, blob.Digest, candidate.ID.DID(), status, cause) +// } else { +// err = replicaStore.Retry(ctx, space, blob.Digest, candidate.ID.DID(), status, cause) +// } +// if err != nil { +// log.Error("failed to store replica record", zap.Error(err)) +// return nil, nil, fmt.Errorf("storing replica record: %w", err) +// } + +// allocTasks = append(allocTasks, allocInv) +// allocReceipts = append(allocReceipts, allocRcpt) +// transferTasks = append(transferTasks, allocRes.Transfer) +// // exclude this provider from next candidate selection (in case there +// // are more replicas to be allocated). +// exclude = append(exclude, candidate.ID) +// break +// } +// } +// } + +// var res spaceblobcap.ReplicateOk +// for _, t := range transferTasks { +// res.Site = append(res.Site, types.Promise{ +// UcanAwait: types.Await{ +// Selector: blobreplicacap.AllocateSiteSelector, +// Link: t.Link(), +// }, +// }) +// } + +// forks := []fx.Effect{} +// for _, t := range allocTasks { +// forks = append(forks, fx.FromInvocation(t)) +// } +// for _, t := range transferTasks { +// forks = append(forks, fx.FromInvocation(t)) +// } +// for _, r := range allocReceipts { +// // as a temporary solution we fork all allocate effects that add inline +// // receipts so they can be delivered to the client. +// conclude, err := issueConclude(id.Signer, r) +// if err != nil { +// log.Error("failed to create conclude invocation for replica allocate receipt", zap.Error(err)) +// return nil, nil, fmt.Errorf("creating conclude invocation: %w", err) +// } +// forks = append(forks, fx.FromInvocation(conclude)) +// } +// for _, r := range transferReceipts { +// // as a temporary solution we fork all transfer effects that add inline +// // receipts so they can be delivered to the client. +// conclude, err := issueConclude(id.Signer, r) +// if err != nil { +// log.Error("failed to create conclude invocation for replica transfer receipt", zap.Error(err)) +// return nil, nil, fmt.Errorf("creating conclude invocation: %w", err) +// } +// forks = append(forks, fx.FromInvocation(conclude)) +// } + +// fx := fx.NewEffects(fx.WithFork(forks...)) + +// return result.Ok[spaceblobcap.ReplicateOk, failure.IPLDBuilderFailure](res), fx, nil +// } +// } + +// type transaction struct { +// invocation invocation.Invocation +// receipt receipt.AnyReceipt +// } + +// type replicaDetail struct { +// allocate transaction +// transfer *transaction +// } + +// // Retrieves details of effect chain for replica allocations. +// // +// // If the allocation failed (receipt in error) then the return value will not +// // include any details about the transfer. i.e. `transfer` will be `nil`. +// // +// // If the receipt for `blob/replica/transfer` was not yet received, it will not +// // be included in the return value. i.e. `transfer.receipt` will be `nil`. +// func replicaFxDetail(ctx context.Context, agentStore agent.Store, rec replica.Record, logger *zap.Logger) (replicaDetail, error) { +// log := logger.With(zap.Stringer("allocation", rec.Cause)) + +// allocRcpt, err := agentStore.GetReceipt(ctx, rec.Cause) +// if err != nil { +// log.Error("failed to get replica allocation receipt", zap.Error(err)) +// return replicaDetail{}, fmt.Errorf("getting allocation receipt: %w", err) +// } + +// // receipt typically contains invocation +// allocInv, ok := allocRcpt.Ran().Invocation() +// if !ok { +// allocInv, err = agentStore.GetInvocation(ctx, rec.Cause) +// if err != nil { +// log.Error("failed to get replica allocation invocation", zap.Error(err)) +// return replicaDetail{}, fmt.Errorf("getting allocation invocation: %w", err) +// } +// } + +// o, x := result.Unwrap(allocRcpt.Out()) +// // if allocation failed, we cannot provide details for transfer +// if x != nil { +// log.Error("cannot get transfer details because allocation failed", zap.Error(fdm.Bind(x))) +// return replicaDetail{ +// allocate: transaction{ +// invocation: allocInv, +// receipt: allocRcpt, +// }, +// }, nil +// } + +// allocOk, err := ipld.Rebind[blobreplicacap.AllocateOk](o, blobreplicacap.AllocateOkType(), types.Converters...) +// if err != nil { +// log.Error("failed to rebind allocation result", zap.Error(err)) +// return replicaDetail{}, fmt.Errorf("rebinding allocation result: %w", err) +// } + +// transferTask, err := ipldutil.ToCID(allocOk.Site.UcanAwait.Link) +// if err != nil { +// return replicaDetail{}, err +// } +// log = log.With(zap.Stringer("transfer", transferTask)) + +// var transferInv invocation.Invocation +// transferRcpt, err := agentStore.GetReceipt(ctx, transferTask) +// if err != nil { +// if !errors.Is(err, agent.ErrReceiptNotFound) { +// log.Error("failed to get replica transfer receipt", zap.Error(err)) +// return replicaDetail{}, fmt.Errorf("getting transfer receipt: %w", err) +// } +// log.Debug("transfer receipt not found, may still be in progress") +// } + +// if transferRcpt != nil { +// transferInv, _ = transferRcpt.Ran().Invocation() +// } +// if transferInv == nil { +// transferInv, err = agentStore.GetInvocation(ctx, transferTask) +// if err != nil { +// log.Error("failed to get replica transfer invocation", zap.Error(err)) +// return replicaDetail{}, fmt.Errorf("getting transfer invocation: %w", err) +// } +// } + +// return replicaDetail{ +// allocate: transaction{ +// invocation: allocInv, +// receipt: allocRcpt, +// }, +// transfer: &transaction{ +// invocation: transferInv, +// receipt: transferRcpt, +// }, +// }, nil +// } + +// func extractLocationCommitment(space did.DID, digest multihash.Multihash, root ipld.Link, blocks blockstore.BlockReader) (delegation.Delegation, assert.LocationCaveats, error) { +// lComm, err := delegation.NewDelegationView(root, blocks) +// if err != nil { +// return nil, assert.LocationCaveats{}, fmt.Errorf("creating location commitment: %w", err) +// } +// if len(lComm.Capabilities()) == 0 { +// return nil, assert.LocationCaveats{}, fmt.Errorf("missing capabilities") +// } +// match, err := assert.Location.Match(validator.NewSource(lComm.Capabilities()[0], lComm)) +// if err != nil { +// return nil, assert.LocationCaveats{}, fmt.Errorf("matching caveats: %w", err) +// } +// nb := match.Value().Nb() +// if nb.Space != space { +// return nil, assert.LocationCaveats{}, fmt.Errorf("space mismatch: expected %s, got %s", space, nb.Space) +// } +// if !bytes.Equal(nb.Content.Hash(), digest) { +// return nil, assert.LocationCaveats{}, fmt.Errorf("digest mismatch: expected %s, got %s", digestutil.Format(digest), digestutil.Format(nb.Content.Hash())) +// } +// return lComm, nb, nil +// } diff --git a/pkg/service/handlers/blob_replicate_test.go b/pkg/service/handlers/blob_replicate_test.go new file mode 100644 index 0000000..4c75f24 --- /dev/null +++ b/pkg/service/handlers/blob_replicate_test.go @@ -0,0 +1,493 @@ +package handlers_test + +// import ( +// "context" +// "fmt" +// "net/url" +// "testing" +// "time" + +// "github.com/fil-forge/libforge/didmailto" +// "github.com/fil-forge/ucantone/did" +// cidlink "github.com/ipld/go-ipld-prime/linking/cid" +// "github.com/storacha/go-libstoracha/capabilities/assert" +// blobreplicacap "github.com/storacha/go-libstoracha/capabilities/blob/replica" +// spaceblobcap "github.com/storacha/go-libstoracha/capabilities/space/blob" +// "github.com/storacha/go-libstoracha/capabilities/types" +// uclient "github.com/storacha/go-ucanto/client" +// "github.com/storacha/go-ucanto/core/delegation" +// "github.com/storacha/go-ucanto/core/invocation" +// "github.com/storacha/go-ucanto/core/receipt/fx" +// "github.com/storacha/go-ucanto/core/result" +// "github.com/storacha/go-ucanto/core/result/failure" +// "github.com/storacha/go-ucanto/core/result/failure/datamodel" +// "github.com/storacha/go-ucanto/principal" +// "github.com/storacha/go-ucanto/principal/signer" +// "github.com/storacha/go-ucanto/server" +// "github.com/storacha/go-ucanto/ucan" +// "github.com/storacha/go-ucanto/validator" +// "github.com/storacha/sprue/internal/config" +// "github.com/storacha/sprue/internal/testutil" +// "github.com/storacha/sprue/pkg/identity" +// "github.com/storacha/sprue/pkg/piriclient" +// "github.com/storacha/sprue/pkg/routing" +// "github.com/storacha/sprue/pkg/service/handlers" +// agent_store "github.com/storacha/sprue/pkg/store/agent/memory" +// "github.com/storacha/sprue/pkg/store/replica" +// replica_store "github.com/storacha/sprue/pkg/store/replica/memory" +// storage_provider_store "github.com/storacha/sprue/pkg/store/storage_provider/memory" +// "github.com/stretchr/testify/require" +// "go.uber.org/zap" +// "go.uber.org/zap/zaptest" +// ) + +// func TestSpaceBlobReplicateHandler(t *testing.T) { +// logger := zaptest.NewLogger(t) +// ctx := t.Context() + +// alice := testutil.Alice +// aliceAccount := testutil.Must(didmailto.Parse("did:mailto:example.com:alice"))(t) +// uploadService := testutil.WebService + +// defaultCfg := config.DeploymentConfig{MaxReplicas: 3} + +// t.Run("invalid space DID", func(t *testing.T) { +// spStore := storage_provider_store.New() +// router := routing.NewService(spStore, logger) +// blobReg, _ := newBlobRegistry() +// replicaStore := replica_store.New() +// agentStore := agent_store.New() +// nodeProvider := piriclient.NewProvider(uploadService, logger) + +// handler := handlers.SpaceBlobReplicateHandler( +// defaultCfg, &identity.Identity{Signer: uploadService}, +// router, blobReg, replicaStore, agentStore, nodeProvider, logger, +// ) + +// digest := testutil.RandomMultihash(t) +// blob := types.Blob{Digest: digest, Size: 1024} +// site := cidlink.Link{Cid: testutil.RandomCID(t)} + +// cap := ucan.NewCapability( +// spaceblobcap.ReplicateAbility, +// "not-a-did", +// spaceblobcap.ReplicateCaveats{Blob: blob, Replicas: 2, Site: site}, +// ) + +// inv, err := invocation.Invoke(alice, uploadService, cap) +// require.NoError(t, err) + +// res, _, err := handler(ctx, cap, inv, nil) +// require.NoError(t, err) + +// _, fail := result.Unwrap(res) +// require.NotNil(t, fail) + +// model := datamodel.Bind(testutil.Must(fail.ToIPLD())(t)) +// require.NotNil(t, model.Name) +// require.Equal(t, handlers.InvalidSpaceErrorName, *model.Name) +// }) + +// t.Run("replicas exceeds max", func(t *testing.T) { +// spStore := storage_provider_store.New() +// router := routing.NewService(spStore, logger) +// blobReg, _ := newBlobRegistry() +// replicaStore := replica_store.New() +// agentStore := agent_store.New() +// nodeProvider := piriclient.NewProvider(uploadService, logger) + +// cfg := config.DeploymentConfig{MaxReplicas: 2} +// handler := handlers.SpaceBlobReplicateHandler( +// cfg, &identity.Identity{Signer: uploadService}, +// router, blobReg, replicaStore, agentStore, nodeProvider, logger, +// ) + +// space := testutil.RandomSigner(t) +// digest := testutil.RandomMultihash(t) +// blob := types.Blob{Digest: digest, Size: 1024} +// site := cidlink.Link{Cid: testutil.RandomCID(t)} + +// cap := ucan.NewCapability( +// spaceblobcap.ReplicateAbility, +// space.DID().String(), +// spaceblobcap.ReplicateCaveats{Blob: blob, Replicas: 3, Site: site}, +// ) + +// inv, err := invocation.Invoke(alice, uploadService, cap) +// require.NoError(t, err) + +// res, _, err := handler(ctx, cap, inv, nil) +// require.NoError(t, err) + +// _, fail := result.Unwrap(res) +// require.NotNil(t, fail) + +// model := datamodel.Bind(testutil.Must(fail.ToIPLD())(t)) +// require.NotNil(t, model.Name) +// require.Equal(t, handlers.ReplicationCountRangeErrorName, *model.Name) +// }) + +// t.Run("blob not found in space", func(t *testing.T) { +// spStore := storage_provider_store.New() +// router := routing.NewService(spStore, logger) +// blobReg, _ := newBlobRegistry() +// replicaStore := replica_store.New() +// agentStore := agent_store.New() +// nodeProvider := piriclient.NewProvider(uploadService, logger) + +// handler := handlers.SpaceBlobReplicateHandler( +// defaultCfg, &identity.Identity{Signer: uploadService}, +// router, blobReg, replicaStore, agentStore, nodeProvider, logger, +// ) + +// space := testutil.RandomSigner(t) +// digest := testutil.RandomMultihash(t) +// blob := types.Blob{Digest: digest, Size: 1024} +// site := cidlink.Link{Cid: testutil.RandomCID(t)} + +// cap := ucan.NewCapability( +// spaceblobcap.ReplicateAbility, +// space.DID().String(), +// spaceblobcap.ReplicateCaveats{Blob: blob, Replicas: 2, Site: site}, +// ) + +// inv, err := invocation.Invoke(alice, uploadService, cap) +// require.NoError(t, err) + +// res, _, err := handler(ctx, cap, inv, nil) +// require.NoError(t, err) + +// _, fail := result.Unwrap(res) +// require.NotNil(t, fail) + +// model := datamodel.Bind(testutil.Must(fail.ToIPLD())(t)) +// require.NotNil(t, model.Name) +// require.Equal(t, handlers.ReplicationSourceNotFoundErrorName, *model.Name) +// }) + +// t.Run("invalid location commitment", func(t *testing.T) { +// spStore := storage_provider_store.New() +// router := routing.NewService(spStore, logger) +// blobReg, consumerStore := newBlobRegistry() +// replicaStore := replica_store.New() +// agentStore := agent_store.New() +// nodeProvider := piriclient.NewProvider(uploadService, logger) + +// handler := handlers.SpaceBlobReplicateHandler( +// defaultCfg, &identity.Identity{Signer: uploadService}, +// router, blobReg, replicaStore, agentStore, nodeProvider, logger, +// ) + +// space := testutil.RandomSigner(t) + +// // provision the space +// err := consumerStore.Add(ctx, uploadService.DID(), space.DID(), aliceAccount, "", testutil.RandomCID(t)) +// require.NoError(t, err) + +// digest := testutil.RandomMultihash(t) +// blob := types.Blob{Digest: digest, Size: 1024} +// // random CID is not a valid location commitment delegation +// site := cidlink.Link{Cid: testutil.RandomCID(t)} + +// // register the blob in the space +// err = blobReg.Register(ctx, space.DID(), blob, testutil.RandomCID(t)) +// require.NoError(t, err) + +// cap := ucan.NewCapability( +// spaceblobcap.ReplicateAbility, +// space.DID().String(), +// spaceblobcap.ReplicateCaveats{Blob: blob, Replicas: 2, Site: site}, +// ) + +// inv, err := invocation.Invoke(alice, uploadService, cap) +// require.NoError(t, err) + +// res, _, err := handler(ctx, cap, inv, nil) +// require.NoError(t, err) + +// _, fail := result.Unwrap(res) +// require.NotNil(t, fail) + +// model := datamodel.Bind(testutil.Must(fail.ToIPLD())(t)) +// require.NotNil(t, model.Name) +// require.Equal(t, handlers.InvalidReplicationSiteErrorName, *model.Name) +// }) + +// t.Run("successful replication with new allocation", func(t *testing.T) { +// uploadID := testutil.Must(identity.New(""))(t) +// uploadService := uploadID.Signer + +// // primary storage provider (already has the blob) +// primaryProvider := testutil.RandomSigner(t) + +// // two replication providers (will each receive a replica) +// replicaProviderA := testutil.RandomSigner(t) +// replicaProviderAURL := testutil.Must(url.Parse("https://replica-a.example.com"))(t) +// replicaProviderAProof := delegateReplicaProviderProof(t, replicaProviderA, uploadService) + +// replicaProviderB := testutil.RandomSigner(t) +// replicaProviderBURL := testutil.Must(url.Parse("https://replica-b.example.com"))(t) +// replicaProviderBProof := delegateReplicaProviderProof(t, replicaProviderB, uploadService) + +// // register both replication providers in routing +// spStore := storage_provider_store.New() +// err := spStore.Put(ctx, *replicaProviderAURL, replicaProviderAProof, 100, nil) +// require.NoError(t, err) +// err = spStore.Put(ctx, *replicaProviderBURL, replicaProviderBProof, 100, nil) +// require.NoError(t, err) + +// router := routing.NewService(spStore, logger) +// blobReg, consumerStore := newBlobRegistry() +// replicaStore := replica_store.New() +// agentStore := agent_store.New() + +// // track which providers received allocations to verify exclusion +// var allocatedProviders []did.DID + +// // mock handler for blob/replica/allocate +// replicaAllocHandler := func( +// ctx context.Context, +// cap ucan.Capability[blobreplicacap.AllocateCaveats], +// inv invocation.Invocation, +// iCtx server.InvocationContext, +// ) (result.Result[blobreplicacap.AllocateOk, failure.IPLDBuilderFailure], fx.Effects, error) { +// allocatedProviders = append(allocatedProviders, iCtx.ID().DID()) + +// // create a transfer invocation to include in the response +// transferCap := ucan.NewCapability( +// "blob/replica/transfer", +// cap.With(), +// ucan.NoCaveats{}, +// ) +// transferInv, err := invocation.Invoke(iCtx.ID(), iCtx.ID(), transferCap) +// if err != nil { +// return nil, nil, err +// } + +// ok := blobreplicacap.AllocateOk{ +// Size: cap.Nb().Blob.Size, +// Site: types.Promise{ +// UcanAwait: types.Await{ +// Selector: blobreplicacap.AllocateSiteSelector, +// Link: transferInv.Link(), +// }, +// }, +// } + +// effects := fx.NewEffects(fx.WithFork(fx.FromInvocation(transferInv))) +// return result.Ok[blobreplicacap.AllocateOk, failure.IPLDBuilderFailure](ok), effects, nil +// } + +// nodeProvider := newMultiMockReplicaNodeProvider(t, uploadService, +// []principal.Signer{replicaProviderA, replicaProviderB}, +// replicaAllocHandler, logger, +// ) + +// handler := handlers.SpaceBlobReplicateHandler( +// defaultCfg, uploadID, +// router, blobReg, replicaStore, agentStore, nodeProvider, logger, +// ) + +// space := testutil.RandomSigner(t) + +// // provision the space +// err = consumerStore.Add(ctx, uploadService.DID(), space.DID(), aliceAccount, "", testutil.RandomCID(t)) +// require.NoError(t, err) + +// digest := testutil.RandomMultihash(t) +// blob := types.Blob{Digest: digest, Size: 1024} + +// // register the blob in the space +// err = blobReg.Register(ctx, space.DID(), blob, testutil.RandomCID(t)) +// require.NoError(t, err) + +// // create a valid location commitment +// blobURL := testutil.Must(url.Parse("https://storage.example.com/blob"))(t) +// locationCap := assert.Location.New(primaryProvider.DID().String(), assert.LocationCaveats{ +// Content: types.FromHash(digest), +// Location: []url.URL{*blobURL}, +// Space: space.DID(), +// }) +// lComm, err := delegation.Delegate( +// primaryProvider, +// uploadService, +// []ucan.Capability[assert.LocationCaveats]{locationCap}, +// delegation.WithExpiration(int(time.Now().Add(time.Hour).Unix())), +// ) +// require.NoError(t, err) + +// site := cidlink.Link{Cid: lComm.Link().(cidlink.Link).Cid} + +// // request 3 replicas: source + 2 new allocations +// cap := ucan.NewCapability( +// spaceblobcap.ReplicateAbility, +// space.DID().String(), +// spaceblobcap.ReplicateCaveats{Blob: blob, Replicas: 3, Site: site}, +// ) + +// inv, err := invocation.Invoke(alice, uploadService, cap) +// require.NoError(t, err) + +// // attach the location commitment blocks to the invocation +// for b, bErr := range lComm.Blocks() { +// require.NoError(t, bErr) +// err = inv.Attach(b) +// require.NoError(t, err) +// } + +// // create a server to get a valid InvocationContext +// srv, err := server.NewServer(uploadService) +// require.NoError(t, err) +// iCtx := srv.Context() + +// res, effects, err := handler(ctx, cap, inv, iCtx) +// require.NoError(t, err) + +// ok, fail := result.Unwrap(res) +// require.Nil(t, fail) +// require.NotNil(t, ok) + +// // should have 2 site promises (one per new replica) +// require.Len(t, ok.Site, 2) +// for _, s := range ok.Site { +// require.Equal(t, blobreplicacap.AllocateSiteSelector, s.UcanAwait.Selector) +// } + +// // should have effects +// require.NotNil(t, effects) + +// // verify both replicas were recorded with distinct providers +// records, err := replicaStore.List(ctx, space.DID(), digest) +// require.NoError(t, err) +// require.Len(t, records, 2) +// require.NotEqual(t, records[0].Provider.DID(), records[1].Provider.DID()) +// for _, r := range records { +// require.Equal(t, replica.Allocated, r.Status) +// } + +// // verify allocations went to two distinct providers (exclusion worked) +// require.Len(t, allocatedProviders, 2) +// require.NotEqual(t, allocatedProviders[0], allocatedProviders[1]) +// }) + +// t.Run("already fully replicated returns success", func(t *testing.T) { +// storageProvider := testutil.RandomSigner(t) +// storageProviderURL := testutil.Must(url.Parse("https://piri.example.com"))(t) +// storageProviderProof := delegateStorageProviderProof(t, storageProvider, uploadService) + +// spStore := storage_provider_store.New() +// err := spStore.Put(ctx, *storageProviderURL, storageProviderProof, 100, nil) +// require.NoError(t, err) + +// router := routing.NewService(spStore, logger) +// blobReg, consumerStore := newBlobRegistry() +// replicaStore := replica_store.New() +// agentStore := agent_store.New() +// nodeProvider := piriclient.NewProvider(uploadService, logger) + +// handler := handlers.SpaceBlobReplicateHandler( +// defaultCfg, &identity.Identity{Signer: uploadService}, +// router, blobReg, replicaStore, agentStore, nodeProvider, logger, +// ) + +// space := testutil.RandomSigner(t) + +// // provision the space +// err = consumerStore.Add(ctx, uploadService.DID(), space.DID(), aliceAccount, "", testutil.RandomCID(t)) +// require.NoError(t, err) + +// digest := testutil.RandomMultihash(t) +// blob := types.Blob{Digest: digest, Size: 1024} +// site := cidlink.Link{Cid: testutil.RandomCID(t)} + +// // register the blob in the space +// err = blobReg.Register(ctx, space.DID(), blob, testutil.RandomCID(t)) +// require.NoError(t, err) + +// // requesting 1 replica means source + 1 = 2 copies total +// // with 0 active replicas, newReplicasCount = 1 - (0 + 1) = 0 +// // so no new allocations needed => success with no effects +// cap := ucan.NewCapability( +// spaceblobcap.ReplicateAbility, +// space.DID().String(), +// spaceblobcap.ReplicateCaveats{Blob: blob, Replicas: 1, Site: site}, +// ) + +// inv, err := invocation.Invoke(alice, uploadService, cap) +// require.NoError(t, err) + +// res, _, err := handler(ctx, cap, inv, nil) +// require.NoError(t, err) + +// ok, fail := result.Unwrap(res) +// require.Nil(t, fail) +// require.NotNil(t, ok) +// }) +// } + +// // multiMockReplicaNodeProvider supports multiple storage node identities, +// // each backed by their own UCAN server for blob/replica/allocate. +// type multiMockReplicaNodeProvider struct { +// agentID ucan.Signer +// servers map[string]server.ServerView[server.Service] +// logger *zap.Logger +// } + +// func (m *multiMockReplicaNodeProvider) Client(id ucan.Principal, endpoint url.URL) (*piriclient.Client, error) { +// srv, ok := m.servers[id.DID().String()] +// if !ok { +// return nil, fmt.Errorf("no mock server for provider %s", id.DID()) +// } +// conn, err := uclient.NewConnection(id, srv) +// if err != nil { +// return nil, err +// } +// return piriclient.NewWithClient(id.DID(), m.agentID, conn, m.logger), nil +// } + +// func newMultiMockReplicaNodeProvider( +// t *testing.T, +// agentID ucan.Signer, +// serviceIDs []principal.Signer, +// allocHandler server.HandlerFunc[blobreplicacap.AllocateCaveats, blobreplicacap.AllocateOk, failure.IPLDBuilderFailure], +// logger *zap.Logger, +// ) *multiMockReplicaNodeProvider { +// t.Helper() + +// servers := make(map[string]server.ServerView[server.Service], len(serviceIDs)) +// for _, serviceID := range serviceIDs { +// ucanSrv, err := server.NewServer( +// serviceID, +// server.WithServiceMethod( +// blobreplicacap.AllocateAbility, +// server.Provide(blobreplicacap.Allocate, allocHandler), +// ), +// server.WithPrincipalResolver(func(ctx context.Context, id did.DID) (did.DID, validator.UnresolvedDID) { +// if id == agentID.DID() { +// if ws, ok := agentID.(signer.WrappedSigner); ok { +// return ws.Unwrap().DID(), nil +// } +// } +// return validator.FailDIDKeyResolution(ctx, id) +// }), +// ) +// require.NoError(t, err) +// servers[serviceID.DID().String()] = ucanSrv +// } + +// return &multiMockReplicaNodeProvider{agentID: agentID, servers: servers, logger: logger} +// } + +// // delegateReplicaProviderProof delegates blob/replica/allocate capability. +// func delegateReplicaProviderProof(t *testing.T, issuer principal.Signer, audience ucan.Principal) delegation.Delegation { +// t.Helper() +// proof, err := delegation.Delegate( +// issuer, +// audience, +// []ucan.Capability[ucan.NoCaveats]{ +// ucan.NewCapability(blobreplicacap.Allocate.Can(), issuer.DID().String(), ucan.NoCaveats{}), +// }, +// ) +// require.NoError(t, err) +// return proof +// } diff --git a/pkg/service/handlers/filecoin_offer.go b/pkg/service/handlers/filecoin_offer.go deleted file mode 100644 index 1b6a3d8..0000000 --- a/pkg/service/handlers/filecoin_offer.go +++ /dev/null @@ -1,48 +0,0 @@ -package handlers - -import ( - "context" - - "go.uber.org/zap" - - filecoincap "github.com/storacha/go-libstoracha/capabilities/filecoin" - "github.com/storacha/go-ucanto/core/invocation" - "github.com/storacha/go-ucanto/core/receipt/fx" - "github.com/storacha/go-ucanto/core/result" - "github.com/storacha/go-ucanto/core/result/failure" - "github.com/storacha/go-ucanto/server" - "github.com/storacha/go-ucanto/ucan" -) - -// WithFilecoinOfferMethod registers the filecoin/offer handler. -// This is a stub implementation that acknowledges Filecoin storage offers. -// TODO: Implement actual Filecoin deal making. -func WithFilecoinOfferMethod(logger *zap.Logger) server.Option { - return server.WithServiceMethod( - filecoincap.OfferAbility, - server.Provide( - filecoincap.Offer, - func(ctx context.Context, - cap ucan.Capability[filecoincap.OfferCaveats], - inv invocation.Invocation, - iCtx server.InvocationContext, - ) (result.Result[filecoincap.OfferOk, failure.IPLDBuilderFailure], fx.Effects, error) { - spaceDID := cap.With() - content := cap.Nb().Content - piece := cap.Nb().Piece - - logger.Debug("filecoin/offer STUB (not implemented)", - zap.String("space", spaceDID), - zap.String("content", content.String()), - zap.String("piece", piece.String())) - - // Return success echoing back the piece CID - return result.Ok[filecoincap.OfferOk, failure.IPLDBuilderFailure]( - filecoincap.OfferOk{ - Piece: piece, - }, - ), nil, nil - }, - ), - ) -} diff --git a/pkg/service/handlers/handlers.go b/pkg/service/handlers/handlers.go new file mode 100644 index 0000000..b98935a --- /dev/null +++ b/pkg/service/handlers/handlers.go @@ -0,0 +1,11 @@ +package handlers + +import ( + "github.com/fil-forge/ucantone/execution" + "github.com/fil-forge/ucantone/validator" +) + +type Handler struct { + Capability validator.Capability + Handler execution.HandlerFunc +} diff --git a/pkg/service/handlers/index_add.go b/pkg/service/handlers/index_add.go new file mode 100644 index 0000000..75b43d6 --- /dev/null +++ b/pkg/service/handlers/index_add.go @@ -0,0 +1,74 @@ +package handlers + +import ( + "fmt" + + "go.uber.org/zap" + + indexcaps "github.com/fil-forge/libforge/capabilities/index" + "github.com/fil-forge/ucantone/errors" + "github.com/fil-forge/ucantone/execution/bindexec" + "github.com/storacha/sprue/pkg/identity" + "github.com/storacha/sprue/pkg/indexerclient" + "github.com/storacha/sprue/pkg/lib/ucan_server" + "github.com/storacha/sprue/pkg/provisioning" + blobregistry "github.com/storacha/sprue/pkg/store/blob_registry" +) + +const IndexNotFoundErrorName = "IndexNotFound" + +var ErrIndexNotFound = errors.New(IndexNotFoundErrorName, "index not found in space") + +func NewIndexAddHandler(id *identity.Identity, provisioningSvc *provisioning.Service, blobRegistry blobregistry.Store, indexerClient *indexerclient.Client, logger *zap.Logger) Handler { + log := logger.With(zap.String("handler", indexcaps.AddCommand)) + return Handler{ + Capability: indexcaps.Add, + Handler: bindexec.NewHandler(func( + req *bindexec.Request[*indexcaps.AddArguments], + res *bindexec.Response[*indexcaps.AddOK], + ) error { + args := req.Task().BindArguments() + space := req.Invocation().Subject() + index := args.Index + + log := log.With( + zap.Stringer("space", space.DID()), + zap.Stringer("index", index), + ) + log.Debug("adding index") + + provs, err := provisioningSvc.ListServiceProviders(req.Context(), space.DID()) + if err != nil { + log.Error("failed to list service providers", zap.Error(err)) + return fmt.Errorf("listing service providers: %w", err) + } + if len(provs) == 0 { + log.Warn("space has no service provider") + return res.SetFailure(errors.New(InsufficientStorageErrorName, "space has no service provider")) + } + + // Ensure the index is stored in the agent's space + _, err = blobRegistry.Get(req.Context(), space.DID(), index.Hash()) + if err != nil { + if errors.Is(err, blobregistry.ErrEntryNotFound) { + log.Warn("index not found in space") + return res.SetFailure(ErrIndexNotFound) + } + log.Error("failed to get index from blob registry", zap.Error(err)) + return err + } + + // Request MUST include a delegation to the upload service that gives it + // the ability to retrieve the index (a /content/retrieve delegation). + // This is re-delegated to the indexer for indexing. + proofStore := ucan_server.NewContainerProofStore(req.Metadata()) + // Publish to indexer with retrieval authorization + if _, err := indexerClient.PublishIndexClaim(req.Context(), space.DID(), index, proofStore); err != nil { + log.Error("failed to publish index claim", zap.Error(err)) + return fmt.Errorf("publishing index claim: %w", err) + } + + return res.SetSuccess(&indexcaps.AddOK{}) + }), + } +} diff --git a/pkg/service/handlers/index_add_test.go b/pkg/service/handlers/index_add_test.go new file mode 100644 index 0000000..a40d368 --- /dev/null +++ b/pkg/service/handlers/index_add_test.go @@ -0,0 +1,244 @@ +package handlers_test + +import ( + "context" + "net/http/httptest" + "net/url" + "testing" + + assertcaps "github.com/fil-forge/libforge/capabilities/assert" + blobcaps "github.com/fil-forge/libforge/capabilities/blob" + contentcaps "github.com/fil-forge/libforge/capabilities/content" + indexcaps "github.com/fil-forge/libforge/capabilities/index" + "github.com/fil-forge/libforge/didmailto" + "github.com/fil-forge/ucantone/did" + edm "github.com/fil-forge/ucantone/errors/datamodel" + "github.com/fil-forge/ucantone/execution" + "github.com/fil-forge/ucantone/execution/bindexec" + "github.com/fil-forge/ucantone/ipld/datamodel" + "github.com/fil-forge/ucantone/principal" + "github.com/fil-forge/ucantone/principal/signer" + "github.com/fil-forge/ucantone/result" + "github.com/fil-forge/ucantone/server" + "github.com/fil-forge/ucantone/ucan/delegation" + "github.com/fil-forge/ucantone/ucan/invocation" + "github.com/fil-forge/ucantone/validator" + "github.com/ipfs/go-cid" + "github.com/storacha/sprue/internal/testutil" + "github.com/storacha/sprue/pkg/identity" + "github.com/storacha/sprue/pkg/indexerclient" + "github.com/storacha/sprue/pkg/provisioning" + "github.com/storacha/sprue/pkg/service/handlers" + consumer_store "github.com/storacha/sprue/pkg/store/consumer/memory" + subscription_store "github.com/storacha/sprue/pkg/store/subscription/memory" + "github.com/stretchr/testify/require" + "go.uber.org/zap/zaptest" +) + +// newMockIndexerServer stands up a UCAN HTTP server that handles /assert/index +// by returning the canned response. Wraps the upload service's did:web identity +// so signatures verify against the underlying did:key. +func newMockIndexerServer( + t *testing.T, + indexerSigner principal.Signer, + uploadService principal.Signer, + indexOK *assertcaps.IndexOK, +) *httptest.Server { + t.Helper() + + resolveDIDKey := func(ctx context.Context, d did.DID) ([]did.DID, error) { + if d == uploadService.DID() { + if w, ok := uploadService.(signer.Unwrapper); ok { + return []did.DID{w.Unwrap().DID()}, nil + } + } + return validator.FailDIDKeyResolution(ctx, d) + } + + srv := server.NewHTTP( + indexerSigner, + server.WithValidationOptions(validator.WithDIDResolver(resolveDIDKey)), + ) + + srv.Handle(assertcaps.Index, bindexec.NewHandler(func( + req *bindexec.Request[*assertcaps.IndexArguments], + res *bindexec.Response[*assertcaps.IndexOK], + ) error { + return res.SetSuccess(indexOK) + })) + + httpSrv := httptest.NewServer(srv) + t.Cleanup(httpSrv.Close) + return httpSrv +} + +// invokeIndexAdd builds an /index/add invocation with optional metadata, +// returning the request and a signed response ready for the handler. +func invokeIndexAdd( + t *testing.T, + ctx context.Context, + agent principal.Signer, + uploadService principal.Signer, + space principal.Signer, + index cid.Cid, + reqOpts ...execution.RequestOption, +) (execution.Request, *execution.ExecResponse) { + t.Helper() + inv, err := indexcaps.Add.Invoke( + agent, + space, + &indexcaps.AddArguments{Index: index}, + invocation.WithAudience(uploadService), + ) + require.NoError(t, err) + req := execution.NewRequest(ctx, inv, reqOpts...) + res, err := execution.NewResponse(req.Invocation().Task().Link(), execution.WithSigner(uploadService)) + require.NoError(t, err) + return req, res +} + +func TestIndexAddHandler(t *testing.T) { + logger := zaptest.NewLogger(t) + ctx := t.Context() + + uploadService := testutil.WebService + alice := testutil.Alice + aliceAccount := testutil.Must(didmailto.New("alice@example.com"))(t) + + id := &identity.Identity{Signer: uploadService} + + t.Run("no service providers", func(t *testing.T) { + consumerStore := consumer_store.New() + subscriptionStore := subscription_store.New() + provisioningSvc := provisioning.NewService(nil, consumerStore, subscriptionStore) + blobReg, _ := newBlobRegistry(t) + + handler := handlers.NewIndexAddHandler(id, provisioningSvc, blobReg, nil, logger) + + space := testutil.RandomSigner(t) + req, res := invokeIndexAdd(t, ctx, alice, uploadService, space, testutil.RandomCID(t)) + + err := handler.Handler(req, res) + require.NoError(t, err) + + _, fail := result.Unwrap(res.Receipt().Out()) + require.NotNil(t, fail) + + model := edm.ErrorModel{} + require.NoError(t, datamodel.Rebind(datamodel.NewAny(fail), &model)) + require.Equal(t, handlers.InsufficientStorageErrorName, model.Name()) + }) + + t.Run("index not found in space", func(t *testing.T) { + blobReg, consumerStore := newBlobRegistry(t) + subscriptionStore := subscription_store.New() + provisioningSvc := provisioning.NewService( + []did.DID{uploadService.DID()}, + consumerStore, + subscriptionStore, + ) + + space := testutil.RandomSigner(t) + require.NoError(t, consumerStore.Add(ctx, uploadService.DID(), space.DID(), aliceAccount, "sub-1", testutil.RandomCID(t))) + + handler := handlers.NewIndexAddHandler(id, provisioningSvc, blobReg, nil, logger) + + // Index blob is not registered for this space. + req, res := invokeIndexAdd(t, ctx, alice, uploadService, space, testutil.RandomCID(t)) + + err := handler.Handler(req, res) + require.NoError(t, err) + + _, fail := result.Unwrap(res.Receipt().Out()) + require.NotNil(t, fail) + + model := edm.ErrorModel{} + require.NoError(t, datamodel.Rebind(datamodel.NewAny(fail), &model)) + require.Equal(t, handlers.IndexNotFoundErrorName, model.Name()) + }) + + t.Run("retrieval auth supplied publishes index claim", func(t *testing.T) { + blobReg, consumerStore := newBlobRegistry(t) + subscriptionStore := subscription_store.New() + provisioningSvc := provisioning.NewService( + []did.DID{uploadService.DID()}, + consumerStore, + subscriptionStore, + ) + + space := testutil.RandomSigner(t) + require.NoError(t, consumerStore.Add(ctx, uploadService.DID(), space.DID(), aliceAccount, "sub-1", testutil.RandomCID(t))) + + indexCID := testutil.RandomCID(t) + indexBlob := blobcaps.Blob{Digest: indexCID.Hash(), Size: 512} + require.NoError(t, blobReg.Register(ctx, space.DID(), indexBlob, testutil.RandomCID(t))) + + // Stand up a mock indexer that returns success on /assert/index. + indexerSigner := testutil.RandomSigner(t) + indexerSrv := newMockIndexerServer(t, indexerSigner, uploadService, &assertcaps.IndexOK{}) + indexerURL := testutil.Must(url.Parse(indexerSrv.URL))(t) + indexerCli, err := indexerclient.New(indexerURL, indexerSigner.DID(), uploadService, logger) + require.NoError(t, err) + + handler := handlers.NewIndexAddHandler(id, provisioningSvc, blobReg, indexerCli, logger) + + // /content/retrieve delegation from space → upload service so the + // handler can build a proof chain that authorizes the indexer to + // retrieve the index blob. + retrievalAuth, err := delegation.Delegate(space, uploadService, space, contentcaps.RetrieveCommand) + require.NoError(t, err) + + req, res := invokeIndexAdd(t, ctx, alice, uploadService, space, indexCID, + execution.WithDelegations(retrievalAuth), + ) + + err = handler.Handler(req, res) + require.NoError(t, err) + + o, fail := result.Unwrap(res.Receipt().Out()) + require.Nil(t, fail) + require.NotNil(t, o) + }) + + t.Run("missing retrieval auth fails to build proof chain", func(t *testing.T) { + blobReg, consumerStore := newBlobRegistry(t) + subscriptionStore := subscription_store.New() + provisioningSvc := provisioning.NewService( + []did.DID{uploadService.DID()}, + consumerStore, + subscriptionStore, + ) + + space := testutil.RandomSigner(t) + require.NoError(t, consumerStore.Add(ctx, uploadService.DID(), space.DID(), aliceAccount, "sub-1", testutil.RandomCID(t))) + + indexCID := testutil.RandomCID(t) + indexBlob := blobcaps.Blob{Digest: indexCID.Hash(), Size: 512} + require.NoError(t, blobReg.Register(ctx, space.DID(), indexBlob, testutil.RandomCID(t))) + + indexerSigner := testutil.RandomSigner(t) + indexerSrv := newMockIndexerServer(t, indexerSigner, uploadService, &assertcaps.IndexOK{}) + indexerURL := testutil.Must(url.Parse(indexerSrv.URL))(t) + indexerCli, err := indexerclient.New(indexerURL, indexerSigner.DID(), uploadService, logger) + require.NoError(t, err) + + handler := handlers.NewIndexAddHandler(id, provisioningSvc, blobReg, indexerCli, logger) + + // No /content/retrieve delegation. The handler invokes /assert/index + // without proofs; the indexer accepts it (our mock has no validation + // on proofs), so this currently still succeeds. If the handler later + // requires retrieval auth before publishing, this test will need a + // stricter mock. + req, res := invokeIndexAdd(t, ctx, alice, uploadService, space, indexCID) + + err = handler.Handler(req, res) + require.NoError(t, err) + + // Currently the indexer client publishes even without retrieval auth + // because the proof chain is empty (not erroring). Document that + // behavior. + o, fail := result.Unwrap(res.Receipt().Out()) + require.Nil(t, fail) + require.NotNil(t, o) + }) +} diff --git a/pkg/service/handlers/provider_add.go b/pkg/service/handlers/provider_add.go index 04bb46a..4cb5adf 100644 --- a/pkg/service/handlers/provider_add.go +++ b/pkg/service/handlers/provider_add.go @@ -1,112 +1,82 @@ package handlers import ( - "context" "fmt" - "go.uber.org/zap" - - "github.com/storacha/go-libstoracha/capabilities/provider" - "github.com/storacha/go-ucanto/core/invocation" - "github.com/storacha/go-ucanto/core/receipt/fx" - "github.com/storacha/go-ucanto/core/result" - "github.com/storacha/go-ucanto/core/result/failure" - "github.com/storacha/go-ucanto/did" - "github.com/storacha/go-ucanto/server" - "github.com/storacha/go-ucanto/ucan" - + providercaps "github.com/fil-forge/libforge/capabilities/provider" + "github.com/fil-forge/libforge/didmailto" + "github.com/fil-forge/ucantone/errors" + "github.com/fil-forge/ucantone/execution/bindexec" "github.com/storacha/sprue/internal/config" "github.com/storacha/sprue/pkg/billing" - "github.com/storacha/sprue/pkg/internal/ipldutil" - "github.com/storacha/sprue/pkg/lib/didmailto" - "github.com/storacha/sprue/pkg/lib/errors" "github.com/storacha/sprue/pkg/provisioning" + "github.com/storacha/sprue/pkg/store/consumer" + "go.uber.org/zap" ) const ( InvalidAccountErrorName = "InvalidAccount" - InvalidProviderErrorName = "InvalidProvider" AccountPlanMissingErrorName = "AccountPlanMissing" ) -// WithProviderAddMethod registers the provider/add handler. -// This handler provisions a space to an account. -func WithProviderAddMethod(deploymentCfg config.DeploymentConfig, provisioningSvc *provisioning.Service, billingSvc *billing.Service, logger *zap.Logger) server.Option { - return server.WithServiceMethod( - provider.AddAbility, - server.Provide( - provider.Add, - ProviderAddHandler(deploymentCfg, provisioningSvc, billingSvc, logger), - ), - ) -} +var ErrAccountPlanMissing = errors.New(AccountPlanMissingErrorName, "account does not have an active payment plan") -func ProviderAddHandler(deploymentCfg config.DeploymentConfig, provisioningSvc *provisioning.Service, billingSvc *billing.Service, logger *zap.Logger) server.HandlerFunc[provider.AddCaveats, provider.AddOk, failure.IPLDBuilderFailure] { - log := logger.With(zap.String("handler", provider.AddAbility)) - return func(ctx context.Context, - cap ucan.Capability[provider.AddCaveats], - inv invocation.Invocation, - iCtx server.InvocationContext, - ) (result.Result[provider.AddOk, failure.IPLDBuilderFailure], fx.Effects, error) { - account, err := didmailto.Parse(cap.With()) - if err != nil { - log.Warn("invalid account", zap.String("account", cap.With())) - return result.Error[provider.AddOk, failure.IPLDBuilderFailure]( - errors.New(InvalidAccountErrorName, "invalid account DID: %v", err), - ), nil, nil - } - log := log.With(zap.Stringer("account", account)) +func NewProviderAddHandler(deploymentCfg config.DeploymentConfig, provisioningSvc *provisioning.Service, billingSvc *billing.Service, logger *zap.Logger) Handler { + log := logger.With(zap.String("handler", providercaps.AddCommand)) + return Handler{ + Capability: providercaps.Add, + Handler: bindexec.NewHandler(func( + req *bindexec.Request[*providercaps.AddArguments], + res *bindexec.Response[*providercaps.AddOK], + ) error { + args := req.Task().BindArguments() + account, err := didmailto.Parse(req.Invocation().Subject().DID().String()) + if err != nil { + log.Warn("invalid account", zap.Stringer("account", req.Invocation().Subject().DID())) + return res.SetFailure(errors.New(InvalidAccountErrorName, "invalid account DID: %v", err)) + } + serviceProvider := args.Provider + space := args.Consumer + cause := req.Invocation().Task().Link() - serviceProvider, err := did.Parse(cap.Nb().Provider) - if err != nil { - log.Warn("invalid provider", zap.String("provider", cap.Nb().Provider)) - return result.Error[provider.AddOk, failure.IPLDBuilderFailure]( - errors.New(InvalidProviderErrorName, "invalid provider DID: %v", err), - ), nil, nil - } - log = log.With(zap.Stringer("provider", serviceProvider)) + log = log.With( + zap.Stringer("account", account), + zap.Stringer("provider", serviceProvider), + zap.Stringer("space", space), + ) + log.Debug("provisioning service for account") - space, err := did.Parse(cap.Nb().Consumer) - if err != nil { - log.Warn("invalid space", zap.String("space", cap.Nb().Consumer)) - return result.Error[provider.AddOk, failure.IPLDBuilderFailure]( - errors.New(InvalidProviderErrorName, "invalid space DID: %v", err), - ), nil, nil - } - log = log.With(zap.Stringer("space", space)) - log.Debug("provisioning service for account", zap.Stringer("account", account)) + if !deploymentCfg.AllowProvisionWithoutPaymentPlan { + // Check if the account has an active payment plan + // If not, return an error + plan, err := billingSvc.PaymentPlan(req.Context(), account) + if err != nil { + if errors.Is(err, billing.ErrMissingPaymentPlan) { + log.Warn("account does not have an active payment plan") + return res.SetFailure(ErrAccountPlanMissing) + } + log.Error("failed to check payment plan", zap.Error(err)) + return fmt.Errorf("checking payment plan: %w", err) + } + log = log.With(zap.Stringer("plan", plan)) + } - if !deploymentCfg.AllowProvisionWithoutPaymentPlan { - // Check if the account has an active payment plan - // If not, return an error - plan, err := billingSvc.PaymentPlan(ctx, account) + sub, err := provisioningSvc.Provision(req.Context(), account, space, serviceProvider, cause) if err != nil { - if errors.Is(err, billing.ErrMissingPaymentPlan) { - log.Warn("account does not have an active payment plan") - return result.Error[provider.AddOk, failure.IPLDBuilderFailure]( - errors.New(AccountPlanMissingErrorName, "account does not have an active payment plan"), - ), nil, nil + if errors.Is(err, provisioning.ErrProviderNotAllowed) { + log.Warn("provider is not allowed for this space") + return res.SetFailure(err) + } + if errors.Is(err, consumer.ErrConsumerExists) { + log.Warn("consumer already exists for this space") + return res.SetFailure(err) } - return nil, nil, fmt.Errorf("checking payment plan: %w", err) + log.Error("failed to provision service", zap.Error(err)) + return fmt.Errorf("provisioning service: %w", err) } - log = log.With(zap.Stringer("plan", plan)) - } - - cause, err := ipldutil.ToCID(inv.Link()) - if err != nil { - return nil, nil, err - } - - sub, err := provisioningSvc.Provision(ctx, account, space, serviceProvider, cause) - if err != nil { - log.Error("failed to provision service", zap.Error(err)) - return nil, nil, fmt.Errorf("provisioning service: %w", err) - } - - log.Debug("service provisioned successfully", zap.String("subscription", sub)) - return result.Ok[provider.AddOk, failure.IPLDBuilderFailure](provider.AddOk{ - Id: sub, - }), nil, nil + log.Debug("service provisioned successfully", zap.String("subscription", sub)) + return res.SetSuccess(&providercaps.AddOK{ID: sub}) + }), } } diff --git a/pkg/service/handlers/provider_add_test.go b/pkg/service/handlers/provider_add_test.go index 77178d6..40c1d2d 100644 --- a/pkg/service/handlers/provider_add_test.go +++ b/pkg/service/handlers/provider_add_test.go @@ -1,256 +1,238 @@ -package handlers +package handlers_test import ( "context" "testing" - "github.com/storacha/go-libstoracha/capabilities/provider" - "github.com/storacha/go-ucanto/core/invocation" - "github.com/storacha/go-ucanto/core/result" - "github.com/storacha/go-ucanto/did" - "github.com/storacha/go-ucanto/ucan" - + providercaps "github.com/fil-forge/libforge/capabilities/provider" + "github.com/fil-forge/libforge/didmailto" + "github.com/fil-forge/ucantone/did" + edm "github.com/fil-forge/ucantone/errors/datamodel" + "github.com/fil-forge/ucantone/execution" + "github.com/fil-forge/ucantone/ipld/datamodel" + "github.com/fil-forge/ucantone/principal" + "github.com/fil-forge/ucantone/result" + "github.com/fil-forge/ucantone/ucan" + "github.com/fil-forge/ucantone/ucan/invocation" "github.com/storacha/sprue/internal/config" + "github.com/storacha/sprue/internal/testutil" "github.com/storacha/sprue/pkg/billing" - "github.com/storacha/sprue/pkg/identity" "github.com/storacha/sprue/pkg/provisioning" - consumermemory "github.com/storacha/sprue/pkg/store/consumer/memory" - customermemory "github.com/storacha/sprue/pkg/store/customer/memory" - subscriptionmemory "github.com/storacha/sprue/pkg/store/subscription/memory" + "github.com/storacha/sprue/pkg/service/handlers" + consumer_store "github.com/storacha/sprue/pkg/store/consumer/memory" + customer_store "github.com/storacha/sprue/pkg/store/customer/memory" + subscription_store "github.com/storacha/sprue/pkg/store/subscription/memory" "github.com/stretchr/testify/require" "go.uber.org/zap/zaptest" ) -func setupProviderAdd(t *testing.T, addCustomer bool) (*identity.Identity, did.DID, did.DID, *provisioning.Service, *billing.Service) { - t.Helper() - - serviceID := newTestIdentity(t) - providerDID := serviceID.Signer.DID() - account := mustMailtoDID(t, "alice@example.com") - product, err := did.Parse("did:web:free.web3.storage") - require.NoError(t, err) - - customerStore := customermemory.New() - if addCustomer { - err = customerStore.Add(context.Background(), account, nil, product, nil, nil) - require.NoError(t, err) - } +type providerAddDeps struct { + provisioningSvc *provisioning.Service + billingSvc *billing.Service + customerStore *customer_store.Store +} +func setupProviderAdd(t *testing.T, providerDID did.DID) *providerAddDeps { + t.Helper() + customerStore := customer_store.New() provisioningSvc := provisioning.NewService( []did.DID{providerDID}, - consumermemory.New(), - subscriptionmemory.New(), + consumer_store.New(), + subscription_store.New(), ) - billingSvc := billing.NewService(customerStore) + return &providerAddDeps{ + provisioningSvc: provisioningSvc, + billingSvc: billingSvc, + customerStore: customerStore, + } +} - return serviceID, providerDID, account, provisioningSvc, billingSvc +// invokeProviderAdd builds a /provider/add invocation with the account as the +// subject (matching the handler's expectation), plus a signed response. +func invokeProviderAdd( + t *testing.T, + ctx context.Context, + agent principal.Signer, + uploadService principal.Signer, + account ucan.Principal, + args *providercaps.AddArguments, +) (execution.Request, *execution.ExecResponse) { + t.Helper() + inv, err := providercaps.Add.Invoke( + agent, + account, + args, + invocation.WithAudience(uploadService), + ) + require.NoError(t, err) + req := execution.NewRequest(ctx, inv) + res, err := execution.NewResponse(req.Invocation().Task().Link(), execution.WithSigner(uploadService)) + require.NoError(t, err) + return req, res } func TestProviderAddHandler(t *testing.T) { logger := zaptest.NewLogger(t) + ctx := t.Context() + + uploadService := testutil.WebService t.Run("success with payment plan", func(t *testing.T) { - deployCfg := config.DeploymentConfig{AllowProvisionWithoutPaymentPlan: false} - serviceID, providerDID, account, provisioningSvc, billingSvc := setupProviderAdd(t, true) - handler := ProviderAddHandler(deployCfg, provisioningSvc, billingSvc, logger) + serviceProvider := testutil.RandomSigner(t) + deps := setupProviderAdd(t, serviceProvider.DID()) - space := newTestIdentity(t) + account := testutil.Must(didmailto.New("alice@example.com"))(t) + product := testutil.Must(did.Parse("did:web:free.web3.storage"))(t) + require.NoError(t, deps.customerStore.Add(ctx, account, nil, product, nil, nil)) - cap := ucan.NewCapability( - provider.AddAbility, - account.String(), - provider.AddCaveats{ - Provider: providerDID.String(), - Consumer: space.DID(), - }, + handler := handlers.NewProviderAddHandler( + config.DeploymentConfig{AllowProvisionWithoutPaymentPlan: false}, + deps.provisioningSvc, deps.billingSvc, logger, ) - agent, err := identity.New("") - require.NoError(t, err) - - inv, err := invocation.Invoke(agent.Signer, serviceID.Signer, cap) - require.NoError(t, err) - - res, _, err := handler(context.Background(), cap, inv, nil) - require.NoError(t, err) - - ok, fail := result.Unwrap(res) - require.Nil(t, fail) - require.NotEmpty(t, ok.Id) - }) - - t.Run("success skipping payment plan check", func(t *testing.T) { - deployCfg := config.DeploymentConfig{AllowProvisionWithoutPaymentPlan: true} - // No customer added — but payment plan check is skipped - serviceID, providerDID, account, provisioningSvc, billingSvc := setupProviderAdd(t, false) - handler := ProviderAddHandler(deployCfg, provisioningSvc, billingSvc, logger) - - space := newTestIdentity(t) - - cap := ucan.NewCapability( - provider.AddAbility, - account.String(), - provider.AddCaveats{ - Provider: providerDID.String(), + space := testutil.RandomSigner(t) + agent := testutil.RandomSigner(t) + req, res := invokeProviderAdd(t, ctx, agent, uploadService, account, + &providercaps.AddArguments{ + Provider: serviceProvider.DID(), Consumer: space.DID(), }, ) - agent, err := identity.New("") + err := handler.Handler(req, res) require.NoError(t, err) - inv, err := invocation.Invoke(agent.Signer, serviceID.Signer, cap) - require.NoError(t, err) - - res, _, err := handler(context.Background(), cap, inv, nil) - require.NoError(t, err) - - ok, fail := result.Unwrap(res) + o, fail := result.Unwrap(res.Receipt().Out()) require.Nil(t, fail) - require.NotEmpty(t, ok.Id) + require.NotNil(t, o) + + ok := providercaps.AddOK{} + require.NoError(t, datamodel.Rebind(datamodel.NewAny(o), &ok)) + require.NotEmpty(t, ok.ID) }) - t.Run("invalid account DID", func(t *testing.T) { - deployCfg := config.DeploymentConfig{} - serviceID, providerDID, _, provisioningSvc, billingSvc := setupProviderAdd(t, false) - handler := ProviderAddHandler(deployCfg, provisioningSvc, billingSvc, logger) + t.Run("success skipping payment plan check", func(t *testing.T) { + serviceProvider := testutil.RandomSigner(t) + deps := setupProviderAdd(t, serviceProvider.DID()) - space := newTestIdentity(t) + // No customer added — but payment plan check is skipped. + handler := handlers.NewProviderAddHandler( + config.DeploymentConfig{AllowProvisionWithoutPaymentPlan: true}, + deps.provisioningSvc, deps.billingSvc, logger, + ) - cap := ucan.NewCapability( - provider.AddAbility, - "not-a-mailto-did", - provider.AddCaveats{ - Provider: providerDID.String(), + account := testutil.Must(didmailto.New("alice@example.com"))(t) + space := testutil.RandomSigner(t) + agent := testutil.RandomSigner(t) + req, res := invokeProviderAdd(t, ctx, agent, uploadService, account, + &providercaps.AddArguments{ + Provider: serviceProvider.DID(), Consumer: space.DID(), }, ) - agent, err := identity.New("") - require.NoError(t, err) - - inv, err := invocation.Invoke(agent.Signer, serviceID.Signer, cap) - require.NoError(t, err) - - res, _, err := handler(context.Background(), cap, inv, nil) + err := handler.Handler(req, res) require.NoError(t, err) - _, fail := result.Unwrap(res) - require.NotNil(t, fail) + o, fail := result.Unwrap(res.Receipt().Out()) + require.Nil(t, fail) + ok := providercaps.AddOK{} + require.NoError(t, datamodel.Rebind(datamodel.NewAny(o), &ok)) + require.NotEmpty(t, ok.ID) }) - t.Run("invalid provider DID", func(t *testing.T) { - deployCfg := config.DeploymentConfig{AllowProvisionWithoutPaymentPlan: true} - serviceID, _, account, provisioningSvc, billingSvc := setupProviderAdd(t, false) - handler := ProviderAddHandler(deployCfg, provisioningSvc, billingSvc, logger) + t.Run("invalid account DID", func(t *testing.T) { + serviceProvider := testutil.RandomSigner(t) + deps := setupProviderAdd(t, serviceProvider.DID()) - space := newTestIdentity(t) + handler := handlers.NewProviderAddHandler( + config.DeploymentConfig{}, + deps.provisioningSvc, deps.billingSvc, logger, + ) - cap := ucan.NewCapability( - provider.AddAbility, - account.String(), - provider.AddCaveats{ - Provider: "bad-provider", + // Subject is a did:key (not a did:mailto), so didmailto.Parse rejects it. + notAMailto := testutil.RandomSigner(t) + space := testutil.RandomSigner(t) + agent := testutil.RandomSigner(t) + req, res := invokeProviderAdd(t, ctx, agent, uploadService, notAMailto, + &providercaps.AddArguments{ + Provider: serviceProvider.DID(), Consumer: space.DID(), }, ) - agent, err := identity.New("") + err := handler.Handler(req, res) require.NoError(t, err) - inv, err := invocation.Invoke(agent.Signer, serviceID.Signer, cap) - require.NoError(t, err) - - res, _, err := handler(context.Background(), cap, inv, nil) - require.NoError(t, err) - - _, fail := result.Unwrap(res) + _, fail := result.Unwrap(res.Receipt().Out()) require.NotNil(t, fail) - }) - t.Run("invalid space DID", func(t *testing.T) { - deployCfg := config.DeploymentConfig{AllowProvisionWithoutPaymentPlan: true} - serviceID, providerDID, account, provisioningSvc, billingSvc := setupProviderAdd(t, false) - handler := ProviderAddHandler(deployCfg, provisioningSvc, billingSvc, logger) - - cap := ucan.NewCapability( - provider.AddAbility, - account.String(), - provider.AddCaveats{ - Provider: providerDID.String(), - Consumer: "bad-space", - }, - ) - - agent, err := identity.New("") - require.NoError(t, err) - - inv, err := invocation.Invoke(agent.Signer, serviceID.Signer, cap) - require.NoError(t, err) - - res, _, err := handler(context.Background(), cap, inv, nil) - require.NoError(t, err) - - _, fail := result.Unwrap(res) - require.NotNil(t, fail) + model := edm.ErrorModel{} + require.NoError(t, datamodel.Rebind(datamodel.NewAny(fail), &model)) + require.Equal(t, handlers.InvalidAccountErrorName, model.Name()) }) t.Run("missing payment plan", func(t *testing.T) { - deployCfg := config.DeploymentConfig{AllowProvisionWithoutPaymentPlan: false} - // No customer added — payment plan check will fail - serviceID, providerDID, account, provisioningSvc, billingSvc := setupProviderAdd(t, false) - handler := ProviderAddHandler(deployCfg, provisioningSvc, billingSvc, logger) - - space := newTestIdentity(t) - - cap := ucan.NewCapability( - provider.AddAbility, - account.String(), - provider.AddCaveats{ - Provider: providerDID.String(), + serviceProvider := testutil.RandomSigner(t) + deps := setupProviderAdd(t, serviceProvider.DID()) + + // No customer added — payment plan check fails with ErrMissingPaymentPlan. + handler := handlers.NewProviderAddHandler( + config.DeploymentConfig{AllowProvisionWithoutPaymentPlan: false}, + deps.provisioningSvc, deps.billingSvc, logger, + ) + + account := testutil.Must(didmailto.New("alice@example.com"))(t) + space := testutil.RandomSigner(t) + agent := testutil.RandomSigner(t) + req, res := invokeProviderAdd(t, ctx, agent, uploadService, account, + &providercaps.AddArguments{ + Provider: serviceProvider.DID(), Consumer: space.DID(), }, ) - agent, err := identity.New("") + err := handler.Handler(req, res) require.NoError(t, err) - inv, err := invocation.Invoke(agent.Signer, serviceID.Signer, cap) - require.NoError(t, err) - - res, _, err := handler(context.Background(), cap, inv, nil) - require.NoError(t, err) - - _, fail := result.Unwrap(res) + _, fail := result.Unwrap(res.Receipt().Out()) require.NotNil(t, fail) + + model := edm.ErrorModel{} + require.NoError(t, datamodel.Rebind(datamodel.NewAny(fail), &model)) + require.Equal(t, handlers.AccountPlanMissingErrorName, model.Name()) }) t.Run("provider not allowed", func(t *testing.T) { - deployCfg := config.DeploymentConfig{AllowProvisionWithoutPaymentPlan: true} - serviceID, _, account, provisioningSvc, billingSvc := setupProviderAdd(t, false) - handler := ProviderAddHandler(deployCfg, provisioningSvc, billingSvc, logger) + serviceProvider := testutil.RandomSigner(t) + deps := setupProviderAdd(t, serviceProvider.DID()) - space := newTestIdentity(t) - otherProvider := newTestIdentity(t) + handler := handlers.NewProviderAddHandler( + config.DeploymentConfig{AllowProvisionWithoutPaymentPlan: true}, + deps.provisioningSvc, deps.billingSvc, logger, + ) - cap := ucan.NewCapability( - provider.AddAbility, - account.String(), - provider.AddCaveats{ + // Args reference a different provider than the one allowed in setup. + otherProvider := testutil.RandomSigner(t) + account := testutil.Must(didmailto.New("alice@example.com"))(t) + space := testutil.RandomSigner(t) + agent := testutil.RandomSigner(t) + req, res := invokeProviderAdd(t, ctx, agent, uploadService, account, + &providercaps.AddArguments{ Provider: otherProvider.DID(), Consumer: space.DID(), }, ) - agent, err := identity.New("") + err := handler.Handler(req, res) require.NoError(t, err) - inv, err := invocation.Invoke(agent.Signer, serviceID.Signer, cap) - require.NoError(t, err) + _, fail := result.Unwrap(res.Receipt().Out()) + require.NotNil(t, fail) - _, _, err = handler(context.Background(), cap, inv, nil) - require.Error(t, err) - require.Contains(t, err.Error(), "provisioning service") + model := edm.ErrorModel{} + require.NoError(t, datamodel.Rebind(datamodel.NewAny(fail), &model)) + require.Equal(t, provisioning.ProviderNotAllowedErrorName, model.Name()) }) } diff --git a/pkg/service/handlers/space_blob_add.go b/pkg/service/handlers/space_blob_add.go deleted file mode 100644 index 41adb65..0000000 --- a/pkg/service/handlers/space_blob_add.go +++ /dev/null @@ -1,430 +0,0 @@ -package handlers - -import ( - "context" - "crypto/ed25519" - "fmt" - "io" - - "github.com/ipld/go-ipld-prime/datamodel" - "github.com/ipld/go-ipld-prime/fluent/qp" - basicnode "github.com/ipld/go-ipld-prime/node/basic" - "github.com/multiformats/go-multihash" - blobcap "github.com/storacha/go-libstoracha/capabilities/blob" - httpcap "github.com/storacha/go-libstoracha/capabilities/http" - spaceblobcap "github.com/storacha/go-libstoracha/capabilities/space/blob" - "github.com/storacha/go-libstoracha/capabilities/types" - ucancap "github.com/storacha/go-libstoracha/capabilities/ucan" - "github.com/storacha/go-libstoracha/digestutil" - "github.com/storacha/go-ucanto/core/car" - "github.com/storacha/go-ucanto/core/delegation" - "github.com/storacha/go-ucanto/core/invocation" - "github.com/storacha/go-ucanto/core/ipld" - "github.com/storacha/go-ucanto/core/message" - "github.com/storacha/go-ucanto/core/receipt" - "github.com/storacha/go-ucanto/core/receipt/fx" - "github.com/storacha/go-ucanto/core/receipt/ran" - "github.com/storacha/go-ucanto/core/result" - "github.com/storacha/go-ucanto/core/result/failure" - "github.com/storacha/go-ucanto/did" - "github.com/storacha/go-ucanto/principal" - ed25519signer "github.com/storacha/go-ucanto/principal/ed25519/signer" - "github.com/storacha/go-ucanto/server" - "github.com/storacha/go-ucanto/ucan" - "go.uber.org/zap" - - "github.com/storacha/sprue/pkg/identity" - "github.com/storacha/sprue/pkg/internal/ipldutil" - "github.com/storacha/sprue/pkg/lib/errors" - "github.com/storacha/sprue/pkg/piriclient" - "github.com/storacha/sprue/pkg/routing" - "github.com/storacha/sprue/pkg/store/agent" - blobregistry "github.com/storacha/sprue/pkg/store/blob_registry" -) - -func WithSpaceBlobAddMethod(id *identity.Identity, router *routing.Service, nodeProvider piriclient.Provider, agentStore agent.Store, blobRegistry blobregistry.Store, logger *zap.Logger) server.Option { - return server.WithServiceMethod( - spaceblobcap.AddAbility, - server.Provide( - spaceblobcap.Add, - SpaceBlobAddHandler(id, router, nodeProvider, agentStore, blobRegistry, logger), - ), - ) -} - -func SpaceBlobAddHandler(id *identity.Identity, router *routing.Service, nodeProvider piriclient.Provider, agentStore agent.Store, blobRegistry blobregistry.Store, logger *zap.Logger) server.HandlerFunc[spaceblobcap.AddCaveats, spaceblobcap.AddOk, failure.IPLDBuilderFailure] { - log := logger.With(zap.String("handler", spaceblobcap.AddAbility)) - return func(ctx context.Context, - cap ucan.Capability[spaceblobcap.AddCaveats], - inv invocation.Invocation, - iCtx server.InvocationContext, - ) (result.Result[spaceblobcap.AddOk, failure.IPLDBuilderFailure], fx.Effects, error) { - blob := cap.Nb().Blob - b58digest := digestutil.Format(blob.Digest) - - log := log.With( - zap.String("space", cap.With()), - zap.Dict( - "blob", - zap.String("digest", b58digest), - zap.Uint64("size", blob.Size), - ), - ) - log.Debug("adding blob") - - space, err := did.Parse(cap.With()) - if err != nil { - return result.Error[spaceblobcap.AddOk, failure.IPLDBuilderFailure]( - errors.New(InvalidSpaceErrorName, "invalid space DID: %v", err), - ), nil, nil - } - - provider, allocInv, allocRcpt, allocOK, err := doAllocate(ctx, router, nodeProvider, agentStore, space, blob, inv.Link(), log) - if err != nil { - if errors.Is(err, routing.ErrCandidateUnavailable) { - return result.Error[spaceblobcap.AddOk, failure.IPLDBuilderFailure]( - routing.ErrCandidateUnavailable, - ), nil, nil - } - log.Error("allocation failed", zap.Error(err)) - return nil, nil, fmt.Errorf("allocating space: %w", err) - } - log = log.With(zap.Stringer("provider", provider.ID.DID())) - - putInv, putRcpt, err := genPut(blob, allocInv, allocOK, log) - if err != nil { - log.Error("failed to generate put invocation", zap.Error(err)) - return nil, nil, fmt.Errorf("generating put invocation: %w", err) - } - - accInv, accRcpt, err := maybeAccept(ctx, agentStore, blobRegistry, nodeProvider, provider, space, blob, putInv, putRcpt, log) - if err != nil { - return nil, nil, err - } - - forks := []fx.Effect{ - fx.FromInvocation(allocInv), - fx.FromInvocation(putInv), - fx.FromInvocation(accInv), - } - - // As a temporary solution we fork all add effects that add inline - // receipts so they can be delivered to the client. - for _, rcpt := range []receipt.AnyReceipt{allocRcpt, putRcpt, accRcpt} { - if rcpt == nil { - continue - } - conclude, err := issueConclude(id.Signer, rcpt) - if err != nil { - log.Error("failed to create conclude invocation for receipt", zap.Error(err)) - return nil, nil, fmt.Errorf("creating conclude invocation: %w", err) - } - forks = append(forks, fx.FromInvocation(conclude)) - } - - fx := fx.NewEffects(fx.WithFork(forks...)) - - return result.Ok[spaceblobcap.AddOk, failure.IPLDBuilderFailure](spaceblobcap.AddOk{ - Site: types.Promise{ - UcanAwait: types.Await{ - Selector: ".out.ok.site", - Link: accInv.Link(), - }, - }, - }), fx, nil - } -} - -// issueConclude generates a ucan/conclude invocation for the given receipt. -func issueConclude(id ucan.Signer, receipt receipt.AnyReceipt) (invocation.IssuedInvocation, error) { - inv, err := ucancap.Conclude.Invoke( - id, - id, - id.DID().String(), - ucancap.ConcludeCaveats{ - Receipt: receipt.Root().Link(), - }, - ) - if err != nil { - return nil, fmt.Errorf("creating conclude invocation: %w", err) - } - // Attach the receipt blocks to the conclude invocation - for blk, err := range receipt.Blocks() { - if err != nil { - return nil, fmt.Errorf("getting receipt block: %w", err) - } - if err := inv.Attach(blk); err != nil { - return nil, fmt.Errorf("attaching receipt block: %w", err) - } - } - return inv, nil -} - -type delegationFetcher struct { - proof delegation.Delegation -} - -// FIXME: this is silly, really the client should authorize the upload service -// to blob/allocate and blob/accept and then the upload service should use that -// delegation to authorize allocate and accept calls to the storage provider. -// This removes the need for storage providers to grant love lived delegations -// to the upload service. We will fix this in UCAN 1.0. -func (df delegationFetcher) GetDelegation(ctx context.Context, audience ucan.Principal) (delegation.Delegation, error) { - if df.proof.Audience().DID() != audience.DID() { - return nil, fmt.Errorf("delegation audience is %s, but invocation requires proof with audience %s", df.proof.Audience().DID(), audience.DID()) - } - return df.proof, nil -} - -func doAllocate( - ctx context.Context, - router *routing.Service, - nodeProvider piriclient.Provider, - agentStore agent.Store, - space did.DID, - blob types.Blob, - cause ucan.Link, - logger *zap.Logger, -) (routing.StorageProviderInfo, invocation.Invocation, receipt.AnyReceipt, blobcap.AllocateOk, error) { - log := logger.With(zap.Stringer("cause", cause)) - log.Debug("doing allocation") - - var exclusions []ucan.Principal - for { - candidate, err := router.SelectStorageProvider(ctx, blob, routing.WithExclusions(exclusions...)) - if err != nil { - log.Error("failed to select storage node", zap.Error(err)) - return routing.StorageProviderInfo{}, nil, nil, blobcap.AllocateOk{}, err - } - log := logger.With(zap.Stringer("candidate", candidate.ID.DID()), zap.String("endpoint", candidate.Endpoint.String())) - log.Debug("selected storage provider candidate") - - client, err := nodeProvider.Client(candidate.ID, candidate.Endpoint) - if err != nil { - log.Error("failed to create piri node", zap.Error(err)) - return routing.StorageProviderInfo{}, nil, nil, blobcap.AllocateOk{}, err - } - - res, inv, rcpt, err := client.Allocate(ctx, &piriclient.AllocateRequest{ - Space: space, - Digest: blob.Digest, - Size: blob.Size, - Cause: cause, - }, delegationFetcher{candidate.Proof}) - if err != nil { - log.Warn("failed to allocate blob", zap.Error(err)) - exclusions = append(exclusions, candidate.ID) - continue - } - - err = writeAgentMessage(ctx, agentStore, []invocation.Invocation{inv}, []receipt.AnyReceipt{rcpt}) - if err != nil { - log.Error("failed to write agent message", zap.Error(err)) - exclusions = append(exclusions, candidate.ID) - continue - } - - return candidate, inv, rcpt, blobcap.AllocateOk{Size: res.Size, Address: res.Address}, nil - } -} - -// TODO(ash): move this into the client -func writeAgentMessage(ctx context.Context, agentStore agent.Store, invs []invocation.Invocation, rcpts []receipt.AnyReceipt) error { - msg, err := message.Build(invs, rcpts) - if err != nil { - return fmt.Errorf("building agent message: %w", err) - } - idx := []agent.IndexEntry{} - for e, err := range agent.Index(msg) { - if err != nil { - return fmt.Errorf("indexing agent message: %w", err) - } - idx = append(idx, e) - } - src, err := io.ReadAll(car.Encode([]ipld.Link{msg.Root().Link()}, msg.Blocks())) - if err != nil { - return fmt.Errorf("reading CAR data: %w", err) - } - return agentStore.Write(ctx, msg, idx, src) -} - -// Generates an invocation to put the blob to the storage provider. It MAY -// return a receipt if the allocation result indicates that the provider already -// has the blob. -func genPut(blob types.Blob, allocInv invocation.Invocation, allocOK blobcap.AllocateOk, logger *zap.Logger) (invocation.Invocation, receipt.AnyReceipt, error) { - log := logger - log.Debug("generating put invocation") - - // Derive the principal that will provide the blob from the blob digest. - // we do this so that any actor with a blob could issue a receipt for the - // `/http/put` invocation. - blobProvider, err := deriveDID(blob.Digest) - if err != nil { - return nil, nil, err - } - - // Create http/put invocation - fct := httpPutFact{ - id: blobProvider.DID().String(), - key: blobProvider.Encode(), - } - - putInv, err := httpcap.Put.Invoke( - blobProvider, - blobProvider, - blobProvider.DID().String(), - httpcap.PutCaveats{ - URL: types.Promise{ - UcanAwait: types.Await{ - Selector: ".out.ok.address.url", - Link: allocInv.Link(), - }, - }, - Headers: types.Promise{ - UcanAwait: types.Await{ - Selector: ".out.ok.address.headers", - Link: allocInv.Link(), - }, - }, - Body: httpcap.Body{ - Digest: blob.Digest, - Size: blob.Size, - }, - }, - // We encode the keys for the blob provider principal that can be used - // by the client to use in order to sign a receipt. Client could - // actually derive the same principal from the blob digest like we did - // above, however by embedding the keys we make API more flexible and - // could in the future generate one-off principals instead. - delegation.WithFacts([]ucan.FactBuilder{fct}), - ) - if err != nil { - return nil, nil, fmt.Errorf("invoking %q: %w", httpcap.PutAbility, err) - } - - var putRcpt receipt.AnyReceipt - - // If no address was provided we have a blob in store already and we can issue - // a receipt for the `/http/put` without requiring blob to be provided. - if allocOK.Address == nil { - log.Info("blob present on provider, issuing receipt for put") - putRcpt, err = receipt.Issue( - blobProvider, - result.Ok[httpcap.PutOk, failure.IPLDBuilderFailure](httpcap.PutOk{}), - ran.FromInvocation(putInv), - ) - if err != nil { - return nil, nil, fmt.Errorf("issuing %q receipt: %w", httpcap.PutAbility, err) - } - } - - return putInv, putRcpt, nil -} - -// Derives did:key principal from (blob) multihash that can be used to -// sign ucan invocations/receipts for the the subject (blob) multihash. -func deriveDID(digest multihash.Multihash) (principal.Signer, error) { - if len(digest) < 32 { - return nil, fmt.Errorf("expected []byte with length %d, got %d", ed25519.SeedSize, len(digest)) - } - seed := digest[len(digest)-32:] - pk := ed25519.NewKeyFromSeed(seed) - return ed25519signer.FromRaw(pk) -} - -// maybeAccept generates and possibly executes a `/blob/accept` invocation if -// the provided put receipt is non-nil and non-failure. -func maybeAccept( - ctx context.Context, - agentStore agent.Store, - blobRegistry blobregistry.Store, - nodeProvider piriclient.Provider, - providerInfo routing.StorageProviderInfo, - space ucan.Principal, - blob types.Blob, - putInv invocation.Invocation, - putRcpt receipt.AnyReceipt, - logger *zap.Logger, -) (invocation.Invocation, receipt.AnyReceipt, error) { - log := logger - log.Debug("generating accept invocation") - - c, err := nodeProvider.Client(providerInfo.ID, providerInfo.Endpoint) - if err != nil { - log.Error("failed to create piri client for accept", zap.Error(err)) - return nil, nil, err - } - - accReq := piriclient.AcceptRequest{ - Space: space.DID(), - Digest: blob.Digest, - Size: blob.Size, - Put: putInv.Link(), - } - - accInv, err := c.AcceptInvocation(ctx, &accReq, delegationFetcher{providerInfo.Proof}) - if err != nil { - log.Error("failed to create accept invocation", zap.Error(err)) - return nil, nil, err - } - - var accRcpt receipt.AnyReceipt - - // If put has already succeeded, we can execute `/blob/accept` right away. - if putRcpt != nil { - _, x := result.Unwrap(putRcpt.Out()) - if x == nil { - res, inv, rcpt, err := c.Accept(ctx, &accReq, delegationFetcher{providerInfo.Proof}) - if err != nil { - log.Error("failed to execute accept on piri", zap.Error(err)) - return nil, nil, err - } - log.Debug("blob accepted", zap.Stringer("site", res.Site)) - - err = writeAgentMessage(ctx, agentStore, []invocation.Invocation{inv}, []receipt.AnyReceipt{rcpt}) - if err != nil { - log.Error("failed to write agent message for accept", zap.Error(err)) - return nil, nil, err - } - - cause, err := ipldutil.ToCID(inv.Link()) - if err != nil { - return nil, nil, err - } - - err = blobRegistry.Register(ctx, space.DID(), blob, cause) - if err != nil { - log.Error("failed to register blob", zap.Error(err)) - return nil, nil, err - } - - accInv = inv - accRcpt = rcpt - } - } - - return accInv, accRcpt, nil -} - -// httpPutFact contains the fact data for the http/put invocation. -// TODO: should move to go-libstoracha -type httpPutFact struct { - id string - key []byte -} - -func (hpf httpPutFact) ToIPLD() (map[string]datamodel.Node, error) { - n, err := qp.BuildMap(basicnode.Prototype.Any, 2, func(ma datamodel.MapAssembler) { - qp.MapEntry(ma, "id", qp.String(hpf.id)) - qp.MapEntry(ma, "keys", qp.Map(1, func(ma datamodel.MapAssembler) { - qp.MapEntry(ma, hpf.id, qp.Bytes(hpf.key)) - })) - }) - if err != nil { - return nil, err - } - - return map[string]datamodel.Node{ - "keys": n, - }, nil -} diff --git a/pkg/service/handlers/space_blob_add_test.go b/pkg/service/handlers/space_blob_add_test.go deleted file mode 100644 index 32fa069..0000000 --- a/pkg/service/handlers/space_blob_add_test.go +++ /dev/null @@ -1,343 +0,0 @@ -package handlers_test - -import ( - "context" - "fmt" - "net/url" - "testing" - - cidlink "github.com/ipld/go-ipld-prime/linking/cid" - blobcap "github.com/storacha/go-libstoracha/capabilities/blob" - spaceblobcap "github.com/storacha/go-libstoracha/capabilities/space/blob" - "github.com/storacha/go-libstoracha/capabilities/types" - uclient "github.com/storacha/go-ucanto/client" - "github.com/storacha/go-ucanto/core/delegation" - "github.com/storacha/go-ucanto/core/invocation" - "github.com/storacha/go-ucanto/core/ipld" - "github.com/storacha/go-ucanto/core/receipt/fx" - "github.com/storacha/go-ucanto/core/result" - "github.com/storacha/go-ucanto/core/result/failure" - "github.com/storacha/go-ucanto/did" - "github.com/storacha/go-ucanto/principal" - "github.com/storacha/go-ucanto/principal/signer" - "github.com/storacha/go-ucanto/server" - "github.com/storacha/go-ucanto/ucan" - "github.com/storacha/go-ucanto/validator" - "github.com/storacha/sprue/internal/testutil" - "github.com/storacha/sprue/pkg/identity" - "github.com/storacha/sprue/pkg/lib/didmailto" - "github.com/storacha/sprue/pkg/piriclient" - "github.com/storacha/sprue/pkg/routing" - "github.com/storacha/sprue/pkg/service/handlers" - agent_store "github.com/storacha/sprue/pkg/store/agent/memory" - blob_registry "github.com/storacha/sprue/pkg/store/blob_registry/memory" - consumer_store "github.com/storacha/sprue/pkg/store/consumer/memory" - metrics_store "github.com/storacha/sprue/pkg/store/metrics/memory" - spacediff_store "github.com/storacha/sprue/pkg/store/space_diff/memory" - storage_provider_store "github.com/storacha/sprue/pkg/store/storage_provider/memory" - "github.com/stretchr/testify/require" - "go.uber.org/zap" - "go.uber.org/zap/zaptest" -) - -func newBlobRegistry() (*blob_registry.Store, *consumer_store.Store) { - consumerStore := consumer_store.New() - return blob_registry.New( - spacediff_store.New(), - consumerStore, - metrics_store.NewSpaceStore(), - metrics_store.New(), - ), consumerStore -} - -// mockNodeProvider is a NodeProvider that creates piri clients connected -// directly to a UCAN server (which implements transport.Channel), bypassing -// HTTP entirely. -type mockNodeProvider struct { - signer ucan.Signer - srv server.ServerView[server.Service] - logger *zap.Logger -} - -func (m *mockNodeProvider) Client(id ucan.Principal, endpoint url.URL) (*piriclient.Client, error) { - // the ID must match the ID of the server this provider started - if id.DID() != m.srv.ID().DID() { - return nil, fmt.Errorf("unexpected client ID %s, expected %s", id.DID(), m.signer.DID()) - } - conn, err := uclient.NewConnection(id, m.srv) - if err != nil { - return nil, err - } - return piriclient.NewWithConnection(id.DID(), m.signer, conn, m.logger), nil -} - -// newMockNodeProvider creates a mock NodeProvider backed by a UCAN server. -// The server is created with the serviceID so that audience validation passes -// (doAllocate passes the upload service signer as the connection audience). -func newMockNodeProvider( - t *testing.T, - agentID ucan.Signer, // the ID of the upload service (signer of invocations) - serviceID principal.Signer, // the ID of the storage node (signer of receipts) - allocHandler server.HandlerFunc[blobcap.AllocateCaveats, blobcap.AllocateOk, failure.IPLDBuilderFailure], - acceptHandler server.HandlerFunc[blobcap.AcceptCaveats, blobcap.AcceptOk, failure.IPLDBuilderFailure], - logger *zap.Logger, -) *mockNodeProvider { - t.Helper() - - ucanSrv, err := server.NewServer( - serviceID, - server.WithServiceMethod( - blobcap.AllocateAbility, - server.Provide(blobcap.Allocate, allocHandler), - ), - server.WithServiceMethod( - blobcap.AcceptAbility, - server.Provide(blobcap.Accept, acceptHandler), - ), - server.WithPrincipalResolver(func(ctx context.Context, id did.DID) (did.DID, validator.UnresolvedDID) { - if id == agentID.DID() { - if ws, ok := agentID.(signer.WrappedSigner); ok { - return ws.Unwrap().DID(), nil - } - } - return validator.FailDIDKeyResolution(ctx, id) - }), - ) - require.NoError(t, err) - - return &mockNodeProvider{signer: agentID, srv: ucanSrv, logger: logger} -} - -func newOkHandler[Caveats any, Ok ipld.Builder](t *testing.T, ok Ok) server.HandlerFunc[Caveats, Ok, failure.IPLDBuilderFailure] { - t.Helper() - return func(ctx context.Context, cap ucan.Capability[Caveats], inv invocation.Invocation, iCtx server.InvocationContext, - ) (result.Result[Ok, failure.IPLDBuilderFailure], fx.Effects, error) { - return result.Ok[Ok, failure.IPLDBuilderFailure](ok), nil, nil - } -} - -// Delegates blob/allocate and blob/accept -func delegateStorageProviderProof(t *testing.T, issuer principal.Signer, audience ucan.Principal) delegation.Delegation { - t.Helper() - proof, err := delegation.Delegate( - issuer, - audience, - []ucan.Capability[ucan.NoCaveats]{ - ucan.NewCapability(blobcap.Allocate.Can(), issuer.DID().String(), ucan.NoCaveats{}), - ucan.NewCapability(blobcap.Accept.Can(), issuer.DID().String(), ucan.NoCaveats{}), - }, - ) - require.NoError(t, err) - return proof -} - -func TestSpaceBlobAddHandler(t *testing.T) { - logger := zaptest.NewLogger(t) - ctx := t.Context() - - alice := testutil.Alice - aliceAccount := testutil.Must(didmailto.Parse("did:mailto:example.com:alice"))(t) - uploadService := testutil.WebService - storageProvider := testutil.RandomSigner(t) - storageProviderURL := testutil.Must(url.Parse("https://piri.example.com"))(t) - storageProviderProof := delegateStorageProviderProof(t, storageProvider, uploadService) - - t.Run("invalid space DID", func(t *testing.T) { - spStore := storage_provider_store.New() - router := routing.NewService(spStore, logger) - agentStore := agent_store.New() - blobReg, _ := newBlobRegistry() - - nodeProvider := piriclient.NewProvider(uploadService, logger) - handler := handlers.SpaceBlobAddHandler(&identity.Identity{Signer: uploadService}, router, nodeProvider, agentStore, blobReg, logger) - - digest := testutil.RandomMultihash(t) - blob := types.Blob{Digest: digest, Size: 1024} - - cap := ucan.NewCapability( - spaceblobcap.AddAbility, - "not-a-did", - spaceblobcap.AddCaveats{Blob: blob}, - ) - - inv, err := invocation.Invoke(alice, uploadService, cap) - require.NoError(t, err) - - res, _, err := handler(ctx, cap, inv, nil) - require.NoError(t, err) - - _, fail := result.Unwrap(res) - require.NotNil(t, fail) - }) - - t.Run("no candidates available", func(t *testing.T) { - spStore := storage_provider_store.New() - router := routing.NewService(spStore, logger) - agentStore := agent_store.New() - blobReg, _ := newBlobRegistry() - - nodeProvider := piriclient.NewProvider(uploadService, logger) - handler := handlers.SpaceBlobAddHandler(&identity.Identity{Signer: uploadService}, router, nodeProvider, agentStore, blobReg, logger) - - space := testutil.RandomSigner(t) - digest := testutil.RandomMultihash(t) - blob := types.Blob{Digest: digest, Size: 1024} - - cap := ucan.NewCapability( - spaceblobcap.AddAbility, - space.DID().String(), - spaceblobcap.AddCaveats{Blob: blob}, - ) - - inv, err := invocation.Invoke(alice, uploadService, cap) - require.NoError(t, err) - - res, _, err := handler(ctx, cap, inv, nil) - require.NoError(t, err) - - _, fail := result.Unwrap(res) - require.NotNil(t, fail) - }) - - t.Run("zero weight providers returns candidate unavailable", func(t *testing.T) { - spStore := storage_provider_store.New() - err := spStore.Put(ctx, *storageProviderURL, storageProviderProof, 0, nil) - require.NoError(t, err) - - router := routing.NewService(spStore, logger) - agentStore := agent_store.New() - blobReg, _ := newBlobRegistry() - - nodeProvider := piriclient.NewProvider(uploadService, logger) - handler := handlers.SpaceBlobAddHandler(&identity.Identity{Signer: uploadService}, router, nodeProvider, agentStore, blobReg, logger) - - space := testutil.RandomSigner(t) - digest := testutil.RandomMultihash(t) - blob := types.Blob{Digest: digest, Size: 1024} - - cap := ucan.NewCapability( - spaceblobcap.AddAbility, - space.DID().String(), - spaceblobcap.AddCaveats{Blob: blob}, - ) - - inv, err := invocation.Invoke(alice, uploadService, cap) - require.NoError(t, err) - - res, _, err := handler(ctx, cap, inv, nil) - require.NoError(t, err) - - _, fail := result.Unwrap(res) - require.NotNil(t, fail) - }) - - t.Run("successful allocation with address", func(t *testing.T) { - putURL := testutil.Must(url.Parse("https://storage.example.com/put"))(t) - allocateOk := blobcap.AllocateOk{ - Size: 1024, - Address: &blobcap.Address{ - URL: *putURL, - Expires: 9999999999, - }, - } - - acceptOk := blobcap.AcceptOk{Site: cidlink.Link{Cid: testutil.RandomCID(t)}} - - nodeProvider := newMockNodeProvider( - t, - uploadService, - storageProvider, - newOkHandler[blobcap.AllocateCaveats](t, allocateOk), - newOkHandler[blobcap.AcceptCaveats](t, acceptOk), - logger, - ) - - spStore := storage_provider_store.New() - err := spStore.Put(ctx, *storageProviderURL, storageProviderProof, 100, nil) - require.NoError(t, err) - - router := routing.NewService(spStore, logger) - agentStore := agent_store.New() - blobReg, consumerStore := newBlobRegistry() - - handler := handlers.SpaceBlobAddHandler(&identity.Identity{Signer: uploadService}, router, nodeProvider, agentStore, blobReg, logger) - - space := testutil.RandomSigner(t) - digest := testutil.RandomMultihash(t) - blob := types.Blob{Digest: digest, Size: 1024} - - // mock "provision" the space, by adding it to the consumer store - err = consumerStore.Add(ctx, uploadService.DID(), space.DID(), aliceAccount, "", testutil.RandomCID(t)) - require.NoError(t, err) - - cap := ucan.NewCapability( - spaceblobcap.AddAbility, - space.DID().String(), - spaceblobcap.AddCaveats{Blob: blob}, - ) - - // we don't need proof alice can invoke this capability since the handler - // is being tested directly and no validation is performed - inv, err := invocation.Invoke(alice, uploadService, cap) - require.NoError(t, err) - - res, effects, err := handler(ctx, cap, inv, nil) - require.NoError(t, err) - - _, fail := result.Unwrap(res) - require.Nil(t, fail) - require.NotNil(t, effects) - }) - - t.Run("successful allocation blob already stored", func(t *testing.T) { - // No address means blob is already on the provider - allocateOk := blobcap.AllocateOk{Size: 1024, Address: nil} - acceptOk := blobcap.AcceptOk{Site: cidlink.Link{Cid: testutil.RandomCID(t)}} - - nodeProvider := newMockNodeProvider( - t, - uploadService, - storageProvider, - newOkHandler[blobcap.AllocateCaveats](t, allocateOk), - newOkHandler[blobcap.AcceptCaveats](t, acceptOk), - logger, - ) - - spStore := storage_provider_store.New() - err := spStore.Put(ctx, *storageProviderURL, storageProviderProof, 100, nil) - require.NoError(t, err) - - router := routing.NewService(spStore, logger) - agentStore := agent_store.New() - blobReg, consumerStore := newBlobRegistry() - - handler := handlers.SpaceBlobAddHandler(&identity.Identity{Signer: uploadService}, router, nodeProvider, agentStore, blobReg, logger) - - space := testutil.RandomSigner(t) - digest := testutil.RandomMultihash(t) - blob := types.Blob{Digest: digest, Size: 1024} - - // mock "provision" the space, by adding it to the consumer store - err = consumerStore.Add(ctx, uploadService.DID(), space.DID(), aliceAccount, "", testutil.RandomCID(t)) - require.NoError(t, err) - - cap := ucan.NewCapability( - spaceblobcap.AddAbility, - space.DID().String(), - spaceblobcap.AddCaveats{Blob: blob}, - ) - - // we don't need proof alice can invoke this capability since the handler - // is being tested directly and no validation is performed - inv, err := invocation.Invoke(alice, uploadService, cap) - require.NoError(t, err) - - res, effects, err := handler(ctx, cap, inv, nil) - require.NoError(t, err) - - _, fail := result.Unwrap(res) - require.Nil(t, fail) - // Should have effects including accept since blob was already stored - require.NotNil(t, effects) - }) -} diff --git a/pkg/service/handlers/space_blob_list.go b/pkg/service/handlers/space_blob_list.go deleted file mode 100644 index ad85c89..0000000 --- a/pkg/service/handlers/space_blob_list.go +++ /dev/null @@ -1,79 +0,0 @@ -package handlers - -import ( - "context" - "fmt" - - cidlink "github.com/ipld/go-ipld-prime/linking/cid" - "github.com/storacha/go-libstoracha/capabilities/space/blob" - "github.com/storacha/go-ucanto/core/invocation" - "github.com/storacha/go-ucanto/core/receipt/fx" - "github.com/storacha/go-ucanto/core/result" - "github.com/storacha/go-ucanto/core/result/failure" - "github.com/storacha/go-ucanto/did" - "github.com/storacha/go-ucanto/server" - "github.com/storacha/go-ucanto/ucan" - "github.com/storacha/sprue/pkg/lib/errors" - blobregistry "github.com/storacha/sprue/pkg/store/blob_registry" - "go.uber.org/zap" -) - -// WithSpaceBlobListMethod registers the space/blob/list handler. -// This handler lists the blobs of a space. -func WithSpaceBlobListMethod(blobRegistry blobregistry.Store, logger *zap.Logger) server.Option { - return server.WithServiceMethod( - blob.ListAbility, - server.Provide(blob.List, SpaceBlobListHandler(blobRegistry, logger)), - ) -} - -func SpaceBlobListHandler(blobRegistry blobregistry.Store, logger *zap.Logger) server.HandlerFunc[blob.ListCaveats, blob.ListOk, failure.IPLDBuilderFailure] { - log := logger.With(zap.String("handler", blob.ListAbility)) - return server.HandlerFunc[blob.ListCaveats, blob.ListOk, failure.IPLDBuilderFailure]( - func(ctx context.Context, - cap ucan.Capability[blob.ListCaveats], - inv invocation.Invocation, - iCtx server.InvocationContext, - ) (result.Result[blob.ListOk, failure.IPLDBuilderFailure], fx.Effects, error) { - args := cap.Nb() - log := log.With(zap.String("space", cap.With())) - - var opts []blobregistry.ListOption - if args.Size != nil { - log = log.With(zap.Uint64("size", *args.Size)) - opts = append(opts, blobregistry.WithListLimit(int(*args.Size))) - } - if args.Cursor != nil { - log = log.With(zap.String("cursor", *args.Cursor)) - opts = append(opts, blobregistry.WithListCursor(*args.Cursor)) - } - log.Debug("listing blobs") - - space, err := did.Parse(cap.With()) - if err != nil { - return result.Error[blob.ListOk, failure.IPLDBuilderFailure]( - errors.New(InvalidSpaceErrorName, "invalid space DID: %v", err), - ), nil, nil - } - - page, err := blobRegistry.List(ctx, space, opts...) - if err != nil { - log.Error("failed to list blobs", zap.Error(err)) - return nil, nil, fmt.Errorf("listing blobs: %w", err) - } - - results := make([]blob.ListBlobItem, 0, len(page.Results)) - for _, r := range page.Results { - results = append(results, blob.ListBlobItem{ - Blob: r.Blob, - Cause: cidlink.Link{Cid: r.Cause}, - InsertedAt: r.InsertedAt, - }) - } - - return result.Ok[blob.ListOk, failure.IPLDBuilderFailure](blob.ListOk{ - Results: results, - Cursor: page.Cursor, - }), nil, nil - }) -} diff --git a/pkg/service/handlers/space_blob_list_test.go b/pkg/service/handlers/space_blob_list_test.go deleted file mode 100644 index ccaf653..0000000 --- a/pkg/service/handlers/space_blob_list_test.go +++ /dev/null @@ -1,202 +0,0 @@ -package handlers_test - -import ( - "testing" - - "github.com/storacha/go-libstoracha/capabilities/space/blob" - "github.com/storacha/go-libstoracha/capabilities/types" - "github.com/storacha/go-ucanto/core/result" - "github.com/storacha/go-ucanto/core/result/failure/datamodel" - "github.com/storacha/sprue/internal/testutil" - "github.com/storacha/sprue/pkg/lib/didmailto" - "github.com/storacha/sprue/pkg/service/handlers" - "github.com/stretchr/testify/require" - "go.uber.org/zap/zaptest" -) - -func TestSpaceBlobListHandler(t *testing.T) { - logger := zaptest.NewLogger(t) - ctx := t.Context() - - uploadService := testutil.WebService - alice := testutil.Alice - aliceAccount := testutil.Must(didmailto.Parse("did:mailto:example.com:alice"))(t) - - t.Run("invalid space DID", func(t *testing.T) { - blobRegistry, _ := newBlobRegistry() - handler := handlers.SpaceBlobListHandler(blobRegistry, logger) - - caveats := blob.ListCaveats{} - inv, err := blob.List.Invoke(alice, uploadService, "not-a-did", caveats) - require.NoError(t, err) - - cap := blob.List.New("not-a-did", caveats) - - res, _, err := handler(ctx, cap, inv, nil) - require.NoError(t, err) - - _, fail := result.Unwrap(res) - require.NotNil(t, fail) - - model := datamodel.Bind(testutil.Must(fail.ToIPLD())(t)) - require.NotNil(t, model.Name) - require.Equal(t, handlers.InvalidSpaceErrorName, *model.Name) - }) - - t.Run("empty list", func(t *testing.T) { - blobRegistry, _ := newBlobRegistry() - handler := handlers.SpaceBlobListHandler(blobRegistry, logger) - - space := testutil.RandomSigner(t) - caveats := blob.ListCaveats{} - inv, err := blob.List.Invoke(alice, uploadService, space.DID().String(), caveats) - require.NoError(t, err) - - cap := blob.List.New(space.DID().String(), caveats) - - res, _, err := handler(ctx, cap, inv, nil) - require.NoError(t, err) - - ok, fail := result.Unwrap(res) - require.Nil(t, fail) - require.Empty(t, ok.Results) - }) - - t.Run("lists blobs", func(t *testing.T) { - blobRegistry, consumerStore := newBlobRegistry() - handler := handlers.SpaceBlobListHandler(blobRegistry, logger) - - space := testutil.RandomSigner(t) - - // Provision the space - err := consumerStore.Add(ctx, uploadService.DID(), space.DID(), aliceAccount, "", testutil.RandomCID(t)) - require.NoError(t, err) - - blob1 := types.Blob{Digest: testutil.RandomMultihash(t), Size: 100} - blob2 := types.Blob{Digest: testutil.RandomMultihash(t), Size: 200} - - err = blobRegistry.Register(ctx, space.DID(), blob1, testutil.RandomCID(t)) - require.NoError(t, err) - err = blobRegistry.Register(ctx, space.DID(), blob2, testutil.RandomCID(t)) - require.NoError(t, err) - - caveats := blob.ListCaveats{} - inv, err := blob.List.Invoke(alice, uploadService, space.DID().String(), caveats) - require.NoError(t, err) - - cap := blob.List.New(space.DID().String(), caveats) - - res, _, err := handler(ctx, cap, inv, nil) - require.NoError(t, err) - - ok, fail := result.Unwrap(res) - require.Nil(t, fail) - require.Len(t, ok.Results, 2) - }) - - t.Run("with size limit", func(t *testing.T) { - blobRegistry, consumerStore := newBlobRegistry() - handler := handlers.SpaceBlobListHandler(blobRegistry, logger) - - space := testutil.RandomSigner(t) - - err := consumerStore.Add(ctx, uploadService.DID(), space.DID(), aliceAccount, "", testutil.RandomCID(t)) - require.NoError(t, err) - - for i := range 3 { - err := blobRegistry.Register(ctx, space.DID(), types.Blob{Digest: testutil.RandomMultihash(t), Size: uint64(i + 1)}, testutil.RandomCID(t)) - require.NoError(t, err) - } - - size := uint64(2) - caveats := blob.ListCaveats{Size: &size} - inv, err := blob.List.Invoke(alice, uploadService, space.DID().String(), caveats) - require.NoError(t, err) - - cap := blob.List.New(space.DID().String(), caveats) - - res, _, err := handler(ctx, cap, inv, nil) - require.NoError(t, err) - - ok, fail := result.Unwrap(res) - require.Nil(t, fail) - require.Len(t, ok.Results, 2) - require.NotNil(t, ok.Cursor) - }) - - t.Run("with cursor pagination", func(t *testing.T) { - blobRegistry, consumerStore := newBlobRegistry() - handler := handlers.SpaceBlobListHandler(blobRegistry, logger) - - space := testutil.RandomSigner(t) - - err := consumerStore.Add(ctx, uploadService.DID(), space.DID(), aliceAccount, "", testutil.RandomCID(t)) - require.NoError(t, err) - - for i := range 3 { - err := blobRegistry.Register(ctx, space.DID(), types.Blob{Digest: testutil.RandomMultihash(t), Size: uint64(i + 1)}, testutil.RandomCID(t)) - require.NoError(t, err) - } - - // First page: size 1 - size := uint64(1) - caveats1 := blob.ListCaveats{Size: &size} - inv1, err := blob.List.Invoke(alice, uploadService, space.DID().String(), caveats1) - require.NoError(t, err) - - cap1 := blob.List.New(space.DID().String(), caveats1) - - res1, _, err := handler(ctx, cap1, inv1, nil) - require.NoError(t, err) - - ok1, fail := result.Unwrap(res1) - require.Nil(t, fail) - require.Len(t, ok1.Results, 1) - require.NotNil(t, ok1.Cursor) - - // Second page using cursor - cursor := *ok1.Cursor - caveats2 := blob.ListCaveats{Cursor: &cursor, Size: &size} - inv2, err := blob.List.Invoke(alice, uploadService, space.DID().String(), caveats2) - require.NoError(t, err) - - cap2 := blob.List.New(space.DID().String(), caveats2) - - res2, _, err := handler(ctx, cap2, inv2, nil) - require.NoError(t, err) - - ok2, fail := result.Unwrap(res2) - require.Nil(t, fail) - require.Len(t, ok2.Results, 1) - - // Results should be different blobs - require.NotEqual(t, ok1.Results[0].Blob.Digest.HexString(), ok2.Results[0].Blob.Digest.HexString()) - }) - - t.Run("does not list blobs from other spaces", func(t *testing.T) { - blobRegistry, consumerStore := newBlobRegistry() - handler := handlers.SpaceBlobListHandler(blobRegistry, logger) - - space1 := testutil.RandomSigner(t) - space2 := testutil.RandomSigner(t) - - err := consumerStore.Add(ctx, uploadService.DID(), space1.DID(), aliceAccount, "", testutil.RandomCID(t)) - require.NoError(t, err) - - err = blobRegistry.Register(ctx, space1.DID(), types.Blob{Digest: testutil.RandomMultihash(t), Size: 100}, testutil.RandomCID(t)) - require.NoError(t, err) - - caveats := blob.ListCaveats{} - inv, err := blob.List.Invoke(alice, uploadService, space2.DID().String(), caveats) - require.NoError(t, err) - - cap := blob.List.New(space2.DID().String(), caveats) - - res, _, err := handler(ctx, cap, inv, nil) - require.NoError(t, err) - - ok, fail := result.Unwrap(res) - require.Nil(t, fail) - require.Empty(t, ok.Results) - }) -} diff --git a/pkg/service/handlers/space_blob_replicate.go b/pkg/service/handlers/space_blob_replicate.go deleted file mode 100644 index 398365f..0000000 --- a/pkg/service/handlers/space_blob_replicate.go +++ /dev/null @@ -1,477 +0,0 @@ -package handlers - -import ( - "bytes" - "context" - "fmt" - "slices" - - "go.uber.org/zap" - - "github.com/multiformats/go-multihash" - "github.com/storacha/go-libstoracha/capabilities/assert" - blobreplicacap "github.com/storacha/go-libstoracha/capabilities/blob/replica" - spaceblobcap "github.com/storacha/go-libstoracha/capabilities/space/blob" - "github.com/storacha/go-libstoracha/capabilities/types" - "github.com/storacha/go-libstoracha/digestutil" - "github.com/storacha/go-ucanto/core/dag/blockstore" - "github.com/storacha/go-ucanto/core/delegation" - "github.com/storacha/go-ucanto/core/invocation" - "github.com/storacha/go-ucanto/core/ipld" - "github.com/storacha/go-ucanto/core/receipt" - "github.com/storacha/go-ucanto/core/receipt/fx" - "github.com/storacha/go-ucanto/core/result" - "github.com/storacha/go-ucanto/core/result/failure" - fdm "github.com/storacha/go-ucanto/core/result/failure/datamodel" - "github.com/storacha/go-ucanto/did" - "github.com/storacha/go-ucanto/server" - "github.com/storacha/go-ucanto/ucan" - "github.com/storacha/go-ucanto/validator" - "github.com/storacha/sprue/internal/config" - "github.com/storacha/sprue/pkg/identity" - "github.com/storacha/sprue/pkg/internal/ipldutil" - "github.com/storacha/sprue/pkg/lib/errors" - "github.com/storacha/sprue/pkg/piriclient" - "github.com/storacha/sprue/pkg/routing" - "github.com/storacha/sprue/pkg/store/agent" - blobregistry "github.com/storacha/sprue/pkg/store/blob_registry" - "github.com/storacha/sprue/pkg/store/replica" -) - -const ( - // Too many or too few replicas were instructed. - ReplicationCountRangeErrorName = "ReplicationCountRangeError" - // There are not enough replication nodes available to replicate the data. - ReplicationCandidateUnavailableErrorName = "ReplicationCandidateUnavailable" - // Blob to replicate was not found in the space. - ReplicationSourceNotFoundErrorName = "ReplicationSourceNotFound" - // The location commitment was invalid in some way. For example, it has - // expired, is revoked, had a signature that did not verify or referenced a - // blob that was not requested to be replicated. - InvalidReplicationSiteErrorName = "InvalidReplicationSite" -) - -var ( - ErrReplicationSourceNotFound = errors.New(ReplicationSourceNotFoundErrorName, "blob to replicate was not found in the space") - ErrReplicationCandidateUnavailable = errors.New(ReplicationCandidateUnavailableErrorName, "no replication candidates available") -) - -// WithSpaceBlobReplicateMethod registers the space/blob/replicate handler. -func WithSpaceBlobReplicateMethod( - cfg config.DeploymentConfig, - id *identity.Identity, - router *routing.Service, - blobRegistry blobregistry.Store, - replicaStore replica.Store, - agentStore agent.Store, - storageNode piriclient.Provider, - logger *zap.Logger, -) server.Option { - return server.WithServiceMethod( - spaceblobcap.ReplicateAbility, - server.Provide( - spaceblobcap.Replicate, - SpaceBlobReplicateHandler(cfg, id, router, blobRegistry, replicaStore, agentStore, storageNode, logger), - ), - ) -} - -func SpaceBlobReplicateHandler( - cfg config.DeploymentConfig, - id *identity.Identity, - router *routing.Service, - blobRegistry blobregistry.Store, - replicaStore replica.Store, - agentStore agent.Store, - storageNode piriclient.Provider, - logger *zap.Logger, -) server.HandlerFunc[spaceblobcap.ReplicateCaveats, spaceblobcap.ReplicateOk, failure.IPLDBuilderFailure] { - log := logger.With(zap.String("handler", spaceblobcap.ReplicateAbility)) - return func(ctx context.Context, - cap ucan.Capability[spaceblobcap.ReplicateCaveats], - inv invocation.Invocation, - iCtx server.InvocationContext, - ) (result.Result[spaceblobcap.ReplicateOk, failure.IPLDBuilderFailure], fx.Effects, error) { - space, err := did.Parse(cap.With()) - if err != nil { - return result.Error[spaceblobcap.ReplicateOk, failure.IPLDBuilderFailure]( - errors.New(InvalidSpaceErrorName, "invalid space DID: %v", err), - ), nil, nil - } - blob := cap.Nb().Blob - replicas := cap.Nb().Replicas - - log := log.With( - zap.Stringer("space", space), - zap.Dict( - "blob", - zap.String("digest", digestutil.Format(blob.Digest)), - zap.Uint64("size", blob.Size), - ), - zap.Uint("replicas", replicas), - ) - log.Debug("replicating blob") - - if replicas > cfg.MaxReplicas { - log.Warn("replication count out of range") - return result.Error[spaceblobcap.ReplicateOk, failure.IPLDBuilderFailure]( - errors.New(ReplicationCountRangeErrorName, "requested number of replicas is greater than maximum: %d", cfg.MaxReplicas), - ), nil, nil - } - - _, err = blobRegistry.Get(ctx, space, blob.Digest) - if err != nil { - if errors.Is(err, blobregistry.ErrEntryNotFound) { - log.Warn("replication source not found in space") - return result.Error[spaceblobcap.ReplicateOk, failure.IPLDBuilderFailure]( - ErrReplicationSourceNotFound, - ), nil, nil - } - log.Error("failed to get blob registration") - return nil, nil, fmt.Errorf("getting blob registration: %w", err) - } - - // check if we have any active replications - records, err := replicaStore.List(ctx, space, blob.Digest) - if err != nil { - log.Error("failed to list replicas", zap.Error(err)) - return nil, nil, fmt.Errorf("listing replicas: %w", err) - } - - // TODO: handle the case where a receipt was not received and the replica - // still exists in "allocated", but has actually timed out/failed. - - var activeReplicas []replica.Record - var failedReplicas []replica.Record - - var allocTasks []invocation.Invocation - var allocReceipts []receipt.AnyReceipt - var transferTasks []invocation.Invocation - var transferReceipts []receipt.AnyReceipt - - for _, r := range records { - if r.Status == replica.Failed { - failedReplicas = append(failedReplicas, r) - } else { - detail, err := replicaFxDetail(ctx, agentStore, r, logger) - if err != nil { - log.Error("failed to get replica details", zap.Error(err)) - return nil, nil, fmt.Errorf("getting replica details: %w", err) - } - activeReplicas = append(activeReplicas, r) - allocTasks = append(allocTasks, detail.allocate.invocation) - allocReceipts = append(allocReceipts, detail.allocate.receipt) - if detail.transfer != nil { - transferTasks = append(transferTasks, detail.transfer.invocation) - if detail.transfer.receipt != nil { - transferReceipts = append(transferReceipts, detail.transfer.receipt) - } - } - } - } - - // Note: We +1 below to include the source blob, which is not recorded in - // the replicas table. - newReplicasCount := int(replicas) - (len(activeReplicas) + 1) - - // TODO: support reducing the number of replicas - if newReplicasCount < 0 { - return result.Error[spaceblobcap.ReplicateOk, failure.IPLDBuilderFailure]( - errors.New(ReplicationCountRangeErrorName, "reducing replica count not implemented"), - ), nil, nil - } - - // lets allocate some replicas! - if newReplicasCount > 0 { - blocks, err := blockstore.NewBlockReader(blockstore.WithBlocksIterator(inv.Blocks())) - if err != nil { - return nil, nil, fmt.Errorf("creating block reader: %w", err) - } - site := cap.Nb().Site - lComm, location, err := extractLocationCommitment(space, blob.Digest, site, blocks) - if err != nil { - log.Warn("failed to extract location commitment", zap.Stringer("site", site), zap.Error(err)) - return result.Error[spaceblobcap.ReplicateOk, failure.IPLDBuilderFailure]( - errors.New(InvalidReplicationSiteErrorName, "invalid location commitment: %s", err.Error()), - ), nil, nil - } - _, err = validator.Claim( - ctx, - assert.Location, - []delegation.Proof{delegation.FromDelegation(lComm)}, - validator.NewClaimContext( - id.Signer.Verifier(), - iCtx.CanIssue, - iCtx.ValidateAuthorization, - iCtx.ResolveProof, - iCtx.ParsePrincipal, - iCtx.ResolveDIDKey, - iCtx.ValidateTimeBounds, - iCtx.AuthorityProofs()..., - ), - ) - if err != nil { - log.Warn("failed to authorize location commitment", zap.Stringer("site", site), zap.Error(err)) - return result.Error[spaceblobcap.ReplicateOk, failure.IPLDBuilderFailure]( - errors.New(InvalidReplicationSiteErrorName, "unauthorized location commitment: %s", err.Error()), - ), nil, nil - } - - urls := make([]string, 0, len(location.Location)) - for _, url := range location.Location { - urls = append(urls, url.String()) - } - siteLogFields := []zap.Field{zap.Stringer("root", site), zap.Strings("locations", urls)} - if location.Range != nil { - siteLogFields = append(siteLogFields, zap.Uint64("offset", location.Range.Offset)) - if location.Range.Length != nil { - siteLogFields = append(siteLogFields, zap.Uint64("length", *location.Range.Length)) - } - } - log = log.With(zap.Dict("site", siteLogFields...)) - log.Debug("allocating space to replicate blob") - - // do not include any nodes where we already have replications - var exclude []ucan.Principal - for _, r := range activeReplicas { - exclude = append(exclude, r.Provider) - } - - for range newReplicasCount { - for { - candidate, err := router.SelectReplicationProvider(ctx, lComm.Issuer(), blob, routing.WithExclusions(exclude...)) - if err != nil { - if errors.Is(err, routing.ErrCandidateUnavailable) { - log.Warn("no replication candidates available") - return result.Error[spaceblobcap.ReplicateOk, failure.IPLDBuilderFailure]( - ErrReplicationCandidateUnavailable, - ), nil, nil - } - log.Error("failed to select replication provider", zap.Error(err)) - return nil, nil, fmt.Errorf("selecting replication provider: %w", err) - } - - log.Debug("selected replication provider", zap.Stringer("provider", candidate.ID.DID())) - client, err := storageNode.Client(candidate.ID, candidate.Endpoint) - if err != nil { - log.Error("failed to create storage node client", zap.Error(err)) - return nil, nil, fmt.Errorf("creating storage node client: %w", err) - } - - allocRes, allocInv, allocRcpt, err := client.ReplicaAllocate(ctx, &piriclient.ReplicaAllocateRequest{ - Space: space, - Digest: blob.Digest, - Size: blob.Size, - Site: lComm, - Cause: inv.Link(), - }, delegationFetcher{proof: candidate.Proof}) - if err != nil { - log.Warn("failed to allocate replica", zap.Error(err)) - exclude = append(exclude, candidate.ID) - continue - } - - // record the invocation and the receipt, so we can retrieve it later - // when we get a blob/replica/transfer receipt in ucan/conclude - err = writeAgentMessage(ctx, agentStore, []invocation.Invocation{allocInv}, []receipt.AnyReceipt{allocRcpt}) - if err != nil { - log.Error("failed to write agent message", zap.Error(err)) - return nil, nil, fmt.Errorf("writing agent message: %w", err) - } - - // write a replication record to the store - firstTimeReplica := !slices.ContainsFunc(failedReplicas, func(r replica.Record) bool { - return r.Provider.DID() == candidate.ID.DID() - }) - status := result.MatchResultR1( - allocRcpt.Out(), - func(o ipld.Node) replica.ReplicationStatus { - return replica.Allocated - }, - func(x ipld.Node) replica.ReplicationStatus { - return replica.Failed - }, - ) - cause, err := ipldutil.ToCID(allocInv.Link()) - if err != nil { - return nil, nil, err - } - if firstTimeReplica { - err = replicaStore.Add(ctx, space, blob.Digest, candidate.ID.DID(), status, cause) - } else { - err = replicaStore.Retry(ctx, space, blob.Digest, candidate.ID.DID(), status, cause) - } - if err != nil { - log.Error("failed to store replica record", zap.Error(err)) - return nil, nil, fmt.Errorf("storing replica record: %w", err) - } - - allocTasks = append(allocTasks, allocInv) - allocReceipts = append(allocReceipts, allocRcpt) - transferTasks = append(transferTasks, allocRes.Transfer) - // exclude this provider from next candidate selection (in case there - // are more replicas to be allocated). - exclude = append(exclude, candidate.ID) - break - } - } - } - - var res spaceblobcap.ReplicateOk - for _, t := range transferTasks { - res.Site = append(res.Site, types.Promise{ - UcanAwait: types.Await{ - Selector: blobreplicacap.AllocateSiteSelector, - Link: t.Link(), - }, - }) - } - - forks := []fx.Effect{} - for _, t := range allocTasks { - forks = append(forks, fx.FromInvocation(t)) - } - for _, t := range transferTasks { - forks = append(forks, fx.FromInvocation(t)) - } - for _, r := range allocReceipts { - // as a temporary solution we fork all allocate effects that add inline - // receipts so they can be delivered to the client. - conclude, err := issueConclude(id.Signer, r) - if err != nil { - log.Error("failed to create conclude invocation for replica allocate receipt", zap.Error(err)) - return nil, nil, fmt.Errorf("creating conclude invocation: %w", err) - } - forks = append(forks, fx.FromInvocation(conclude)) - } - for _, r := range transferReceipts { - // as a temporary solution we fork all transfer effects that add inline - // receipts so they can be delivered to the client. - conclude, err := issueConclude(id.Signer, r) - if err != nil { - log.Error("failed to create conclude invocation for replica transfer receipt", zap.Error(err)) - return nil, nil, fmt.Errorf("creating conclude invocation: %w", err) - } - forks = append(forks, fx.FromInvocation(conclude)) - } - - fx := fx.NewEffects(fx.WithFork(forks...)) - - return result.Ok[spaceblobcap.ReplicateOk, failure.IPLDBuilderFailure](res), fx, nil - } -} - -type transaction struct { - invocation invocation.Invocation - receipt receipt.AnyReceipt -} - -type replicaDetail struct { - allocate transaction - transfer *transaction -} - -// Retrieves details of effect chain for replica allocations. -// -// If the allocation failed (receipt in error) then the return value will not -// include any details about the transfer. i.e. `transfer` will be `nil`. -// -// If the receipt for `blob/replica/transfer` was not yet received, it will not -// be included in the return value. i.e. `transfer.receipt` will be `nil`. -func replicaFxDetail(ctx context.Context, agentStore agent.Store, rec replica.Record, logger *zap.Logger) (replicaDetail, error) { - log := logger.With(zap.Stringer("allocation", rec.Cause)) - - allocRcpt, err := agentStore.GetReceipt(ctx, rec.Cause) - if err != nil { - log.Error("failed to get replica allocation receipt", zap.Error(err)) - return replicaDetail{}, fmt.Errorf("getting allocation receipt: %w", err) - } - - // receipt typically contains invocation - allocInv, ok := allocRcpt.Ran().Invocation() - if !ok { - allocInv, err = agentStore.GetInvocation(ctx, rec.Cause) - if err != nil { - log.Error("failed to get replica allocation invocation", zap.Error(err)) - return replicaDetail{}, fmt.Errorf("getting allocation invocation: %w", err) - } - } - - o, x := result.Unwrap(allocRcpt.Out()) - // if allocation failed, we cannot provide details for transfer - if x != nil { - log.Error("cannot get transfer details because allocation failed", zap.Error(fdm.Bind(x))) - return replicaDetail{ - allocate: transaction{ - invocation: allocInv, - receipt: allocRcpt, - }, - }, nil - } - - allocOk, err := ipld.Rebind[blobreplicacap.AllocateOk](o, blobreplicacap.AllocateOkType(), types.Converters...) - if err != nil { - log.Error("failed to rebind allocation result", zap.Error(err)) - return replicaDetail{}, fmt.Errorf("rebinding allocation result: %w", err) - } - - transferTask, err := ipldutil.ToCID(allocOk.Site.UcanAwait.Link) - if err != nil { - return replicaDetail{}, err - } - log = log.With(zap.Stringer("transfer", transferTask)) - - var transferInv invocation.Invocation - transferRcpt, err := agentStore.GetReceipt(ctx, transferTask) - if err != nil { - if !errors.Is(err, agent.ErrReceiptNotFound) { - log.Error("failed to get replica transfer receipt", zap.Error(err)) - return replicaDetail{}, fmt.Errorf("getting transfer receipt: %w", err) - } - log.Debug("transfer receipt not found, may still be in progress") - } - - if transferRcpt != nil { - transferInv, _ = transferRcpt.Ran().Invocation() - } - if transferInv == nil { - transferInv, err = agentStore.GetInvocation(ctx, transferTask) - if err != nil { - log.Error("failed to get replica transfer invocation", zap.Error(err)) - return replicaDetail{}, fmt.Errorf("getting transfer invocation: %w", err) - } - } - - return replicaDetail{ - allocate: transaction{ - invocation: allocInv, - receipt: allocRcpt, - }, - transfer: &transaction{ - invocation: transferInv, - receipt: transferRcpt, - }, - }, nil -} - -func extractLocationCommitment(space did.DID, digest multihash.Multihash, root ipld.Link, blocks blockstore.BlockReader) (delegation.Delegation, assert.LocationCaveats, error) { - lComm, err := delegation.NewDelegationView(root, blocks) - if err != nil { - return nil, assert.LocationCaveats{}, fmt.Errorf("creating location commitment: %w", err) - } - if len(lComm.Capabilities()) == 0 { - return nil, assert.LocationCaveats{}, fmt.Errorf("missing capabilities") - } - match, err := assert.Location.Match(validator.NewSource(lComm.Capabilities()[0], lComm)) - if err != nil { - return nil, assert.LocationCaveats{}, fmt.Errorf("matching caveats: %w", err) - } - nb := match.Value().Nb() - if nb.Space != space { - return nil, assert.LocationCaveats{}, fmt.Errorf("space mismatch: expected %s, got %s", space, nb.Space) - } - if !bytes.Equal(nb.Content.Hash(), digest) { - return nil, assert.LocationCaveats{}, fmt.Errorf("digest mismatch: expected %s, got %s", digestutil.Format(digest), digestutil.Format(nb.Content.Hash())) - } - return lComm, nb, nil -} diff --git a/pkg/service/handlers/space_blob_replicate_test.go b/pkg/service/handlers/space_blob_replicate_test.go deleted file mode 100644 index 3f4e2ed..0000000 --- a/pkg/service/handlers/space_blob_replicate_test.go +++ /dev/null @@ -1,493 +0,0 @@ -package handlers_test - -import ( - "context" - "fmt" - "net/url" - "testing" - "time" - - cidlink "github.com/ipld/go-ipld-prime/linking/cid" - "github.com/storacha/go-libstoracha/capabilities/assert" - blobreplicacap "github.com/storacha/go-libstoracha/capabilities/blob/replica" - spaceblobcap "github.com/storacha/go-libstoracha/capabilities/space/blob" - "github.com/storacha/go-libstoracha/capabilities/types" - uclient "github.com/storacha/go-ucanto/client" - "github.com/storacha/go-ucanto/core/delegation" - "github.com/storacha/go-ucanto/core/invocation" - "github.com/storacha/go-ucanto/core/receipt/fx" - "github.com/storacha/go-ucanto/core/result" - "github.com/storacha/go-ucanto/core/result/failure" - "github.com/storacha/go-ucanto/core/result/failure/datamodel" - "github.com/storacha/go-ucanto/did" - "github.com/storacha/go-ucanto/principal" - "github.com/storacha/go-ucanto/principal/signer" - "github.com/storacha/go-ucanto/server" - "github.com/storacha/go-ucanto/ucan" - "github.com/storacha/go-ucanto/validator" - "github.com/storacha/sprue/internal/config" - "github.com/storacha/sprue/internal/testutil" - "github.com/storacha/sprue/pkg/identity" - "github.com/storacha/sprue/pkg/lib/didmailto" - "github.com/storacha/sprue/pkg/piriclient" - "github.com/storacha/sprue/pkg/routing" - "github.com/storacha/sprue/pkg/service/handlers" - agent_store "github.com/storacha/sprue/pkg/store/agent/memory" - "github.com/storacha/sprue/pkg/store/replica" - replica_store "github.com/storacha/sprue/pkg/store/replica/memory" - storage_provider_store "github.com/storacha/sprue/pkg/store/storage_provider/memory" - "github.com/stretchr/testify/require" - "go.uber.org/zap" - "go.uber.org/zap/zaptest" -) - -func TestSpaceBlobReplicateHandler(t *testing.T) { - logger := zaptest.NewLogger(t) - ctx := t.Context() - - alice := testutil.Alice - aliceAccount := testutil.Must(didmailto.Parse("did:mailto:example.com:alice"))(t) - uploadService := testutil.WebService - - defaultCfg := config.DeploymentConfig{MaxReplicas: 3} - - t.Run("invalid space DID", func(t *testing.T) { - spStore := storage_provider_store.New() - router := routing.NewService(spStore, logger) - blobReg, _ := newBlobRegistry() - replicaStore := replica_store.New() - agentStore := agent_store.New() - nodeProvider := piriclient.NewProvider(uploadService, logger) - - handler := handlers.SpaceBlobReplicateHandler( - defaultCfg, &identity.Identity{Signer: uploadService}, - router, blobReg, replicaStore, agentStore, nodeProvider, logger, - ) - - digest := testutil.RandomMultihash(t) - blob := types.Blob{Digest: digest, Size: 1024} - site := cidlink.Link{Cid: testutil.RandomCID(t)} - - cap := ucan.NewCapability( - spaceblobcap.ReplicateAbility, - "not-a-did", - spaceblobcap.ReplicateCaveats{Blob: blob, Replicas: 2, Site: site}, - ) - - inv, err := invocation.Invoke(alice, uploadService, cap) - require.NoError(t, err) - - res, _, err := handler(ctx, cap, inv, nil) - require.NoError(t, err) - - _, fail := result.Unwrap(res) - require.NotNil(t, fail) - - model := datamodel.Bind(testutil.Must(fail.ToIPLD())(t)) - require.NotNil(t, model.Name) - require.Equal(t, handlers.InvalidSpaceErrorName, *model.Name) - }) - - t.Run("replicas exceeds max", func(t *testing.T) { - spStore := storage_provider_store.New() - router := routing.NewService(spStore, logger) - blobReg, _ := newBlobRegistry() - replicaStore := replica_store.New() - agentStore := agent_store.New() - nodeProvider := piriclient.NewProvider(uploadService, logger) - - cfg := config.DeploymentConfig{MaxReplicas: 2} - handler := handlers.SpaceBlobReplicateHandler( - cfg, &identity.Identity{Signer: uploadService}, - router, blobReg, replicaStore, agentStore, nodeProvider, logger, - ) - - space := testutil.RandomSigner(t) - digest := testutil.RandomMultihash(t) - blob := types.Blob{Digest: digest, Size: 1024} - site := cidlink.Link{Cid: testutil.RandomCID(t)} - - cap := ucan.NewCapability( - spaceblobcap.ReplicateAbility, - space.DID().String(), - spaceblobcap.ReplicateCaveats{Blob: blob, Replicas: 3, Site: site}, - ) - - inv, err := invocation.Invoke(alice, uploadService, cap) - require.NoError(t, err) - - res, _, err := handler(ctx, cap, inv, nil) - require.NoError(t, err) - - _, fail := result.Unwrap(res) - require.NotNil(t, fail) - - model := datamodel.Bind(testutil.Must(fail.ToIPLD())(t)) - require.NotNil(t, model.Name) - require.Equal(t, handlers.ReplicationCountRangeErrorName, *model.Name) - }) - - t.Run("blob not found in space", func(t *testing.T) { - spStore := storage_provider_store.New() - router := routing.NewService(spStore, logger) - blobReg, _ := newBlobRegistry() - replicaStore := replica_store.New() - agentStore := agent_store.New() - nodeProvider := piriclient.NewProvider(uploadService, logger) - - handler := handlers.SpaceBlobReplicateHandler( - defaultCfg, &identity.Identity{Signer: uploadService}, - router, blobReg, replicaStore, agentStore, nodeProvider, logger, - ) - - space := testutil.RandomSigner(t) - digest := testutil.RandomMultihash(t) - blob := types.Blob{Digest: digest, Size: 1024} - site := cidlink.Link{Cid: testutil.RandomCID(t)} - - cap := ucan.NewCapability( - spaceblobcap.ReplicateAbility, - space.DID().String(), - spaceblobcap.ReplicateCaveats{Blob: blob, Replicas: 2, Site: site}, - ) - - inv, err := invocation.Invoke(alice, uploadService, cap) - require.NoError(t, err) - - res, _, err := handler(ctx, cap, inv, nil) - require.NoError(t, err) - - _, fail := result.Unwrap(res) - require.NotNil(t, fail) - - model := datamodel.Bind(testutil.Must(fail.ToIPLD())(t)) - require.NotNil(t, model.Name) - require.Equal(t, handlers.ReplicationSourceNotFoundErrorName, *model.Name) - }) - - t.Run("invalid location commitment", func(t *testing.T) { - spStore := storage_provider_store.New() - router := routing.NewService(spStore, logger) - blobReg, consumerStore := newBlobRegistry() - replicaStore := replica_store.New() - agentStore := agent_store.New() - nodeProvider := piriclient.NewProvider(uploadService, logger) - - handler := handlers.SpaceBlobReplicateHandler( - defaultCfg, &identity.Identity{Signer: uploadService}, - router, blobReg, replicaStore, agentStore, nodeProvider, logger, - ) - - space := testutil.RandomSigner(t) - - // provision the space - err := consumerStore.Add(ctx, uploadService.DID(), space.DID(), aliceAccount, "", testutil.RandomCID(t)) - require.NoError(t, err) - - digest := testutil.RandomMultihash(t) - blob := types.Blob{Digest: digest, Size: 1024} - // random CID is not a valid location commitment delegation - site := cidlink.Link{Cid: testutil.RandomCID(t)} - - // register the blob in the space - err = blobReg.Register(ctx, space.DID(), blob, testutil.RandomCID(t)) - require.NoError(t, err) - - cap := ucan.NewCapability( - spaceblobcap.ReplicateAbility, - space.DID().String(), - spaceblobcap.ReplicateCaveats{Blob: blob, Replicas: 2, Site: site}, - ) - - inv, err := invocation.Invoke(alice, uploadService, cap) - require.NoError(t, err) - - res, _, err := handler(ctx, cap, inv, nil) - require.NoError(t, err) - - _, fail := result.Unwrap(res) - require.NotNil(t, fail) - - model := datamodel.Bind(testutil.Must(fail.ToIPLD())(t)) - require.NotNil(t, model.Name) - require.Equal(t, handlers.InvalidReplicationSiteErrorName, *model.Name) - }) - - t.Run("successful replication with new allocation", func(t *testing.T) { - uploadID := testutil.Must(identity.New(""))(t) - uploadService := uploadID.Signer - - // primary storage provider (already has the blob) - primaryProvider := testutil.RandomSigner(t) - - // two replication providers (will each receive a replica) - replicaProviderA := testutil.RandomSigner(t) - replicaProviderAURL := testutil.Must(url.Parse("https://replica-a.example.com"))(t) - replicaProviderAProof := delegateReplicaProviderProof(t, replicaProviderA, uploadService) - - replicaProviderB := testutil.RandomSigner(t) - replicaProviderBURL := testutil.Must(url.Parse("https://replica-b.example.com"))(t) - replicaProviderBProof := delegateReplicaProviderProof(t, replicaProviderB, uploadService) - - // register both replication providers in routing - spStore := storage_provider_store.New() - err := spStore.Put(ctx, *replicaProviderAURL, replicaProviderAProof, 100, nil) - require.NoError(t, err) - err = spStore.Put(ctx, *replicaProviderBURL, replicaProviderBProof, 100, nil) - require.NoError(t, err) - - router := routing.NewService(spStore, logger) - blobReg, consumerStore := newBlobRegistry() - replicaStore := replica_store.New() - agentStore := agent_store.New() - - // track which providers received allocations to verify exclusion - var allocatedProviders []did.DID - - // mock handler for blob/replica/allocate - replicaAllocHandler := func( - ctx context.Context, - cap ucan.Capability[blobreplicacap.AllocateCaveats], - inv invocation.Invocation, - iCtx server.InvocationContext, - ) (result.Result[blobreplicacap.AllocateOk, failure.IPLDBuilderFailure], fx.Effects, error) { - allocatedProviders = append(allocatedProviders, iCtx.ID().DID()) - - // create a transfer invocation to include in the response - transferCap := ucan.NewCapability( - "blob/replica/transfer", - cap.With(), - ucan.NoCaveats{}, - ) - transferInv, err := invocation.Invoke(iCtx.ID(), iCtx.ID(), transferCap) - if err != nil { - return nil, nil, err - } - - ok := blobreplicacap.AllocateOk{ - Size: cap.Nb().Blob.Size, - Site: types.Promise{ - UcanAwait: types.Await{ - Selector: blobreplicacap.AllocateSiteSelector, - Link: transferInv.Link(), - }, - }, - } - - effects := fx.NewEffects(fx.WithFork(fx.FromInvocation(transferInv))) - return result.Ok[blobreplicacap.AllocateOk, failure.IPLDBuilderFailure](ok), effects, nil - } - - nodeProvider := newMultiMockReplicaNodeProvider(t, uploadService, - []principal.Signer{replicaProviderA, replicaProviderB}, - replicaAllocHandler, logger, - ) - - handler := handlers.SpaceBlobReplicateHandler( - defaultCfg, uploadID, - router, blobReg, replicaStore, agentStore, nodeProvider, logger, - ) - - space := testutil.RandomSigner(t) - - // provision the space - err = consumerStore.Add(ctx, uploadService.DID(), space.DID(), aliceAccount, "", testutil.RandomCID(t)) - require.NoError(t, err) - - digest := testutil.RandomMultihash(t) - blob := types.Blob{Digest: digest, Size: 1024} - - // register the blob in the space - err = blobReg.Register(ctx, space.DID(), blob, testutil.RandomCID(t)) - require.NoError(t, err) - - // create a valid location commitment - blobURL := testutil.Must(url.Parse("https://storage.example.com/blob"))(t) - locationCap := assert.Location.New(primaryProvider.DID().String(), assert.LocationCaveats{ - Content: types.FromHash(digest), - Location: []url.URL{*blobURL}, - Space: space.DID(), - }) - lComm, err := delegation.Delegate( - primaryProvider, - uploadService, - []ucan.Capability[assert.LocationCaveats]{locationCap}, - delegation.WithExpiration(int(time.Now().Add(time.Hour).Unix())), - ) - require.NoError(t, err) - - site := cidlink.Link{Cid: lComm.Link().(cidlink.Link).Cid} - - // request 3 replicas: source + 2 new allocations - cap := ucan.NewCapability( - spaceblobcap.ReplicateAbility, - space.DID().String(), - spaceblobcap.ReplicateCaveats{Blob: blob, Replicas: 3, Site: site}, - ) - - inv, err := invocation.Invoke(alice, uploadService, cap) - require.NoError(t, err) - - // attach the location commitment blocks to the invocation - for b, bErr := range lComm.Blocks() { - require.NoError(t, bErr) - err = inv.Attach(b) - require.NoError(t, err) - } - - // create a server to get a valid InvocationContext - srv, err := server.NewServer(uploadService) - require.NoError(t, err) - iCtx := srv.Context() - - res, effects, err := handler(ctx, cap, inv, iCtx) - require.NoError(t, err) - - ok, fail := result.Unwrap(res) - require.Nil(t, fail) - require.NotNil(t, ok) - - // should have 2 site promises (one per new replica) - require.Len(t, ok.Site, 2) - for _, s := range ok.Site { - require.Equal(t, blobreplicacap.AllocateSiteSelector, s.UcanAwait.Selector) - } - - // should have effects - require.NotNil(t, effects) - - // verify both replicas were recorded with distinct providers - records, err := replicaStore.List(ctx, space.DID(), digest) - require.NoError(t, err) - require.Len(t, records, 2) - require.NotEqual(t, records[0].Provider.DID(), records[1].Provider.DID()) - for _, r := range records { - require.Equal(t, replica.Allocated, r.Status) - } - - // verify allocations went to two distinct providers (exclusion worked) - require.Len(t, allocatedProviders, 2) - require.NotEqual(t, allocatedProviders[0], allocatedProviders[1]) - }) - - t.Run("already fully replicated returns success", func(t *testing.T) { - storageProvider := testutil.RandomSigner(t) - storageProviderURL := testutil.Must(url.Parse("https://piri.example.com"))(t) - storageProviderProof := delegateStorageProviderProof(t, storageProvider, uploadService) - - spStore := storage_provider_store.New() - err := spStore.Put(ctx, *storageProviderURL, storageProviderProof, 100, nil) - require.NoError(t, err) - - router := routing.NewService(spStore, logger) - blobReg, consumerStore := newBlobRegistry() - replicaStore := replica_store.New() - agentStore := agent_store.New() - nodeProvider := piriclient.NewProvider(uploadService, logger) - - handler := handlers.SpaceBlobReplicateHandler( - defaultCfg, &identity.Identity{Signer: uploadService}, - router, blobReg, replicaStore, agentStore, nodeProvider, logger, - ) - - space := testutil.RandomSigner(t) - - // provision the space - err = consumerStore.Add(ctx, uploadService.DID(), space.DID(), aliceAccount, "", testutil.RandomCID(t)) - require.NoError(t, err) - - digest := testutil.RandomMultihash(t) - blob := types.Blob{Digest: digest, Size: 1024} - site := cidlink.Link{Cid: testutil.RandomCID(t)} - - // register the blob in the space - err = blobReg.Register(ctx, space.DID(), blob, testutil.RandomCID(t)) - require.NoError(t, err) - - // requesting 1 replica means source + 1 = 2 copies total - // with 0 active replicas, newReplicasCount = 1 - (0 + 1) = 0 - // so no new allocations needed => success with no effects - cap := ucan.NewCapability( - spaceblobcap.ReplicateAbility, - space.DID().String(), - spaceblobcap.ReplicateCaveats{Blob: blob, Replicas: 1, Site: site}, - ) - - inv, err := invocation.Invoke(alice, uploadService, cap) - require.NoError(t, err) - - res, _, err := handler(ctx, cap, inv, nil) - require.NoError(t, err) - - ok, fail := result.Unwrap(res) - require.Nil(t, fail) - require.NotNil(t, ok) - }) -} - -// multiMockReplicaNodeProvider supports multiple storage node identities, -// each backed by their own UCAN server for blob/replica/allocate. -type multiMockReplicaNodeProvider struct { - agentID ucan.Signer - servers map[string]server.ServerView[server.Service] - logger *zap.Logger -} - -func (m *multiMockReplicaNodeProvider) Client(id ucan.Principal, endpoint url.URL) (*piriclient.Client, error) { - srv, ok := m.servers[id.DID().String()] - if !ok { - return nil, fmt.Errorf("no mock server for provider %s", id.DID()) - } - conn, err := uclient.NewConnection(id, srv) - if err != nil { - return nil, err - } - return piriclient.NewWithConnection(id.DID(), m.agentID, conn, m.logger), nil -} - -func newMultiMockReplicaNodeProvider( - t *testing.T, - agentID ucan.Signer, - serviceIDs []principal.Signer, - allocHandler server.HandlerFunc[blobreplicacap.AllocateCaveats, blobreplicacap.AllocateOk, failure.IPLDBuilderFailure], - logger *zap.Logger, -) *multiMockReplicaNodeProvider { - t.Helper() - - servers := make(map[string]server.ServerView[server.Service], len(serviceIDs)) - for _, serviceID := range serviceIDs { - ucanSrv, err := server.NewServer( - serviceID, - server.WithServiceMethod( - blobreplicacap.AllocateAbility, - server.Provide(blobreplicacap.Allocate, allocHandler), - ), - server.WithPrincipalResolver(func(ctx context.Context, id did.DID) (did.DID, validator.UnresolvedDID) { - if id == agentID.DID() { - if ws, ok := agentID.(signer.WrappedSigner); ok { - return ws.Unwrap().DID(), nil - } - } - return validator.FailDIDKeyResolution(ctx, id) - }), - ) - require.NoError(t, err) - servers[serviceID.DID().String()] = ucanSrv - } - - return &multiMockReplicaNodeProvider{agentID: agentID, servers: servers, logger: logger} -} - -// delegateReplicaProviderProof delegates blob/replica/allocate capability. -func delegateReplicaProviderProof(t *testing.T, issuer principal.Signer, audience ucan.Principal) delegation.Delegation { - t.Helper() - proof, err := delegation.Delegate( - issuer, - audience, - []ucan.Capability[ucan.NoCaveats]{ - ucan.NewCapability(blobreplicacap.Allocate.Can(), issuer.DID().String(), ucan.NoCaveats{}), - }, - ) - require.NoError(t, err) - return proof -} diff --git a/pkg/service/handlers/space_index_add.go b/pkg/service/handlers/space_index_add.go deleted file mode 100644 index 7955913..0000000 --- a/pkg/service/handlers/space_index_add.go +++ /dev/null @@ -1,148 +0,0 @@ -package handlers - -import ( - "context" - "fmt" - - "go.uber.org/zap" - - spaceindexcap "github.com/storacha/go-libstoracha/capabilities/space/index" - "github.com/storacha/go-ucanto/core/dag/blockstore" - "github.com/storacha/go-ucanto/core/delegation" - "github.com/storacha/go-ucanto/core/invocation" - "github.com/storacha/go-ucanto/core/ipld" - "github.com/storacha/go-ucanto/core/receipt/fx" - "github.com/storacha/go-ucanto/core/result" - "github.com/storacha/go-ucanto/core/result/failure" - "github.com/storacha/go-ucanto/did" - "github.com/storacha/go-ucanto/server" - "github.com/storacha/go-ucanto/ucan" - "github.com/storacha/sprue/pkg/indexerclient" - "github.com/storacha/sprue/pkg/internal/ipldutil" - "github.com/storacha/sprue/pkg/lib/errors" - "github.com/storacha/sprue/pkg/provisioning" - blobregistry "github.com/storacha/sprue/pkg/store/blob_registry" -) - -const IndexNotFoundErrorName = "IndexNotFound" - -var ErrIndexNotFound = errors.New(IndexNotFoundErrorName, "index not found in space") - -// extractRetrievalAuth extracts the space/content/retrieve delegation from the -// invocation facts. Guppy includes this delegation so the indexer can fetch -// the index blob from storage providers that require UCAN authorization. -func extractRetrievalAuth(inv invocation.Invocation) (delegation.Delegation, error) { - var authLink ipld.Link - for _, fact := range inv.Facts() { - if v, ok := fact["retrievalAuth"]; ok { - if node, ok := v.(ipld.Node); ok { - link, err := node.AsLink() - if err == nil { - authLink = link - break - } - } - } - } - if authLink == nil { - return nil, fmt.Errorf("retrievalAuth fact not found in invocation") - } - - // Build delegation from invocation blocks - bs, err := blockstore.NewBlockReader(blockstore.WithBlocksIterator(inv.Blocks())) - if err != nil { - return nil, fmt.Errorf("creating block reader: %w", err) - } - dlg, err := delegation.NewDelegationView(authLink, bs) - if err != nil { - return nil, fmt.Errorf("creating delegation view: %w", err) - } - return dlg, nil -} - -// WithSpaceIndexAddMethod registers the space/index/add handler. -// This handler publishes index claims to the indexer service. -func WithSpaceIndexAddMethod(provisioningSvc *provisioning.Service, blobRegistry blobregistry.Store, indexerClient *indexerclient.Client, logger *zap.Logger) server.Option { - return server.WithServiceMethod( - spaceindexcap.AddAbility, - server.Provide( - spaceindexcap.Add, - SpaceIndexAddHandler(provisioningSvc, blobRegistry, indexerClient, logger), - ), - ) -} - -func SpaceIndexAddHandler(provisioningSvc *provisioning.Service, blobRegistry blobregistry.Store, indexerClient *indexerclient.Client, logger *zap.Logger) server.HandlerFunc[spaceindexcap.AddCaveats, spaceindexcap.AddOk, failure.IPLDBuilderFailure] { - log := logger.With(zap.String("handler", spaceindexcap.AddAbility)) - return func(ctx context.Context, - cap ucan.Capability[spaceindexcap.AddCaveats], - inv invocation.Invocation, - iCtx server.InvocationContext, - ) (result.Result[spaceindexcap.AddOk, failure.IPLDBuilderFailure], fx.Effects, error) { - space, err := did.Parse(cap.With()) - if err != nil { - return result.Error[spaceindexcap.AddOk, failure.IPLDBuilderFailure]( - errors.New(InvalidSpaceErrorName, "invalid space DID: %v", err), - ), nil, nil - } - index, err := ipldutil.ToCID(cap.Nb().Index) - if err != nil { - return nil, nil, err - } - content, err := ipldutil.ToCID(cap.Nb().Content) - if err != nil { - return nil, nil, err - } - - log := log.With( - zap.Stringer("space", space), - zap.Stringer("index", index), - zap.Stringer("content", content), - ) - log.Debug("adding index") - - provs, err := provisioningSvc.ListServiceProviders(ctx, space) - if err != nil { - log.Error("failed to list service providers", zap.Error(err)) - return nil, nil, fmt.Errorf("listing service providers: %w", err) - } - if len(provs) == 0 { - log.Warn("space has no service provider") - return result.Error[spaceindexcap.AddOk, failure.IPLDBuilderFailure]( - errors.New(InsufficientStorageErrorName, "space has no service provider"), - ), nil, nil - } - - // Ensure the index is stored in the agent's space - _, err = blobRegistry.Get(ctx, space, index.Hash()) - if err != nil { - if errors.Is(err, blobregistry.ErrEntryNotFound) { - log.Warn("index not found in space") - return result.Error[spaceindexcap.AddOk, failure.IPLDBuilderFailure]( - ErrIndexNotFound, - ), nil, nil - } - log.Error("failed to get index from blob registry", zap.Error(err)) - return nil, nil, err - } - - // Extract retrievalAuth delegation from invocation facts - // Guppy provides this so the indexer can fetch the index blob from piri - retrievalAuth, err := extractRetrievalAuth(inv) - if err != nil { - log.Error("failed to extract retrieval auth", zap.Error(err)) - return nil, nil, fmt.Errorf("extracting retrieval auth: %w", err) - } - log.Debug("extracted retrieval auth", zap.Stringer("root", retrievalAuth.Link())) - - // Publish to indexer with retrieval authorization - if err := indexerClient.PublishIndexClaim(ctx, space, content, index, retrievalAuth); err != nil { - log.Error("failed to publish index claim", zap.Error(err)) - return nil, nil, fmt.Errorf("publishing index claim: %w", err) - } - - return result.Ok[spaceindexcap.AddOk, failure.IPLDBuilderFailure]( - spaceindexcap.AddOk{}, - ), nil, nil - } -} diff --git a/pkg/service/handlers/space_index_add_test.go b/pkg/service/handlers/space_index_add_test.go deleted file mode 100644 index e8daf0c..0000000 --- a/pkg/service/handlers/space_index_add_test.go +++ /dev/null @@ -1,228 +0,0 @@ -package handlers_test - -import ( - "testing" - - ipldprime "github.com/ipld/go-ipld-prime" - cidlink "github.com/ipld/go-ipld-prime/linking/cid" - "github.com/ipld/go-ipld-prime/node/basicnode" - spaceindexcap "github.com/storacha/go-libstoracha/capabilities/space/index" - "github.com/storacha/go-libstoracha/capabilities/types" - "github.com/storacha/go-ucanto/core/delegation" - "github.com/storacha/go-ucanto/core/invocation" - "github.com/storacha/go-ucanto/core/result" - "github.com/storacha/go-ucanto/core/result/failure/datamodel" - "github.com/storacha/go-ucanto/ucan" - "github.com/storacha/sprue/internal/testutil" - "github.com/storacha/sprue/pkg/lib/didmailto" - "github.com/storacha/sprue/pkg/provisioning" - "github.com/storacha/sprue/pkg/service/handlers" - consumer_store "github.com/storacha/sprue/pkg/store/consumer/memory" - subscription_store "github.com/storacha/sprue/pkg/store/subscription/memory" - "github.com/stretchr/testify/require" - "go.uber.org/zap/zaptest" -) - -func TestSpaceIndexAddHandler(t *testing.T) { - logger := zaptest.NewLogger(t) - ctx := t.Context() - - alice := testutil.Alice - aliceAccount := testutil.Must(didmailto.Parse("did:mailto:example.com:alice"))(t) - uploadService := testutil.WebService - - t.Run("invalid space DID", func(t *testing.T) { - consumerStore := consumer_store.New() - subscriptionStore := subscription_store.New() - provisioningSvc := provisioning.NewService(nil, consumerStore, subscriptionStore) - blobReg, _ := newBlobRegistry() - - handler := handlers.SpaceIndexAddHandler(provisioningSvc, blobReg, nil, logger) - - index := cidlink.Link{Cid: testutil.RandomCID(t)} - content := cidlink.Link{Cid: testutil.RandomCID(t)} - cap := ucan.NewCapability( - spaceindexcap.AddAbility, - "not-a-did", - spaceindexcap.AddCaveats{Index: index, Content: content}, - ) - - inv, err := invocation.Invoke(alice, uploadService, cap) - require.NoError(t, err) - - res, _, err := handler(ctx, cap, inv, nil) - require.NoError(t, err) - - _, fail := result.Unwrap(res) - require.NotNil(t, fail) - - model := datamodel.Bind(testutil.Must(fail.ToIPLD())(t)) - require.NotNil(t, model.Name) - require.Equal(t, handlers.InvalidSpaceErrorName, *model.Name) - }) - - t.Run("no service providers", func(t *testing.T) { - consumerStore := consumer_store.New() - subscriptionStore := subscription_store.New() - provisioningSvc := provisioning.NewService(nil, consumerStore, subscriptionStore) - blobReg, _ := newBlobRegistry() - - handler := handlers.SpaceIndexAddHandler(provisioningSvc, blobReg, nil, logger) - - space := testutil.RandomSigner(t) - index := cidlink.Link{Cid: testutil.RandomCID(t)} - content := cidlink.Link{Cid: testutil.RandomCID(t)} - cap := ucan.NewCapability( - spaceindexcap.AddAbility, - space.DID().String(), - spaceindexcap.AddCaveats{Index: index, Content: content}, - ) - - inv, err := invocation.Invoke(alice, uploadService, cap) - require.NoError(t, err) - - res, _, err := handler(ctx, cap, inv, nil) - require.NoError(t, err) - - _, fail := result.Unwrap(res) - require.NotNil(t, fail) - - model := datamodel.Bind(testutil.Must(fail.ToIPLD())(t)) - require.NotNil(t, model.Name) - require.Equal(t, handlers.InsufficientStorageErrorName, *model.Name) - }) - - t.Run("index not found in space", func(t *testing.T) { - consumerStore := consumer_store.New() - subscriptionStore := subscription_store.New() - provisioningSvc := provisioning.NewService(nil, consumerStore, subscriptionStore) - blobReg, _ := newBlobRegistry() - - // provision the space - err := consumerStore.Add(ctx, uploadService.DID(), testutil.RandomSigner(t).DID(), aliceAccount, "", testutil.RandomCID(t)) - require.NoError(t, err) - - space := testutil.RandomSigner(t) - err = consumerStore.Add(ctx, uploadService.DID(), space.DID(), aliceAccount, "", testutil.RandomCID(t)) - require.NoError(t, err) - - handler := handlers.SpaceIndexAddHandler(provisioningSvc, blobReg, nil, logger) - - index := cidlink.Link{Cid: testutil.RandomCID(t)} - content := cidlink.Link{Cid: testutil.RandomCID(t)} - cap := ucan.NewCapability( - spaceindexcap.AddAbility, - space.DID().String(), - spaceindexcap.AddCaveats{Index: index, Content: content}, - ) - - inv, err := invocation.Invoke(alice, uploadService, cap) - require.NoError(t, err) - - res, _, err := handler(ctx, cap, inv, nil) - require.NoError(t, err) - - _, fail := result.Unwrap(res) - require.NotNil(t, fail) - - model := datamodel.Bind(testutil.Must(fail.ToIPLD())(t)) - require.NotNil(t, model.Name) - require.Equal(t, handlers.IndexNotFoundErrorName, *model.Name) - }) - - t.Run("missing retrieval auth fact", func(t *testing.T) { - blobReg, consumerSt := newBlobRegistry() - subscriptionStore := subscription_store.New() - provisioningSvc := provisioning.NewService(nil, consumerSt, subscriptionStore) - - space := testutil.RandomSigner(t) - err := consumerSt.Add(ctx, uploadService.DID(), space.DID(), aliceAccount, "", testutil.RandomCID(t)) - require.NoError(t, err) - - // register the index blob in the space - indexCID := testutil.RandomCID(t) - indexBlob := types.Blob{Digest: indexCID.Hash(), Size: 512} - err = blobReg.Register(ctx, space.DID(), indexBlob, testutil.RandomCID(t)) - require.NoError(t, err) - - handler := handlers.SpaceIndexAddHandler(provisioningSvc, blobReg, nil, logger) - - index := cidlink.Link{Cid: indexCID} - content := cidlink.Link{Cid: testutil.RandomCID(t)} - cap := ucan.NewCapability( - spaceindexcap.AddAbility, - space.DID().String(), - spaceindexcap.AddCaveats{Index: index, Content: content}, - ) - - // invocation without retrievalAuth fact - inv, err := invocation.Invoke(alice, uploadService, cap) - require.NoError(t, err) - - _, _, err = handler(ctx, cap, inv, nil) - require.Error(t, err) - require.Contains(t, err.Error(), "retrievalAuth") - }) - - t.Run("retrieval auth fact present", func(t *testing.T) { - blobReg, consumerSt := newBlobRegistry() - subscriptionStore := subscription_store.New() - provisioningSvc := provisioning.NewService(nil, consumerSt, subscriptionStore) - - space := testutil.RandomSigner(t) - err := consumerSt.Add(ctx, uploadService.DID(), space.DID(), aliceAccount, "", testutil.RandomCID(t)) - require.NoError(t, err) - - // register the index blob in the space - indexCID := testutil.RandomCID(t) - indexBlob := types.Blob{Digest: indexCID.Hash(), Size: 512} - err = blobReg.Register(ctx, space.DID(), indexBlob, testutil.RandomCID(t)) - require.NoError(t, err) - - handler := handlers.SpaceIndexAddHandler(provisioningSvc, blobReg, nil, logger) - - index := cidlink.Link{Cid: indexCID} - content := cidlink.Link{Cid: testutil.RandomCID(t)} - cap := ucan.NewCapability( - spaceindexcap.AddAbility, - space.DID().String(), - spaceindexcap.AddCaveats{Index: index, Content: content}, - ) - - // create a retrieval auth delegation to include as a fact - retrievalAuth, err := delegation.Delegate( - alice, - uploadService, - []ucan.Capability[ucan.NoCaveats]{ - ucan.NewCapability("space/content/retrieve", space.DID().String(), ucan.NoCaveats{}), - }, - ) - require.NoError(t, err) - - inv, err := invocation.Invoke(alice, uploadService, cap, - delegation.WithFacts([]ucan.FactBuilder{ - retrievalAuthFact{link: retrievalAuth.Link()}, - }), - delegation.WithProof(delegation.FromDelegation(retrievalAuth)), - ) - require.NoError(t, err) - - // handler will fail at indexerClient.PublishIndexClaim since client is nil, - // but it should get past the extractRetrievalAuth step - _, _, err = handler(ctx, cap, inv, nil) - require.Error(t, err) - // should NOT be a retrievalAuth error - require.NotContains(t, err.Error(), "retrievalAuth") - }) -} - -// retrievalAuthFact implements ucan.FactBuilder for test invocations. -type retrievalAuthFact struct { - link ucan.Link -} - -func (f retrievalAuthFact) ToIPLD() (map[string]ipldprime.Node, error) { - return map[string]ipldprime.Node{ - "retrievalAuth": basicnode.NewLink(f.link), - }, nil -} diff --git a/pkg/service/handlers/ucan_conclude.go b/pkg/service/handlers/ucan_conclude.go index 439c1b1..5c55ee7 100644 --- a/pkg/service/handlers/ucan_conclude.go +++ b/pkg/service/handlers/ucan_conclude.go @@ -5,117 +5,104 @@ import ( "fmt" "maps" "slices" - "time" - captypes "github.com/storacha/go-libstoracha/capabilities/types" - ucancap "github.com/storacha/go-libstoracha/capabilities/ucan" - "github.com/storacha/go-ucanto/core/invocation" - "github.com/storacha/go-ucanto/core/receipt" - "github.com/storacha/go-ucanto/core/receipt/fx" - "github.com/storacha/go-ucanto/core/result" - "github.com/storacha/go-ucanto/core/result/failure" - "github.com/storacha/go-ucanto/server" - "github.com/storacha/go-ucanto/ucan" + ucancaps "github.com/fil-forge/libforge/capabilities/ucan" + "github.com/fil-forge/ucantone/errors" + "github.com/fil-forge/ucantone/execution/bindexec" + "github.com/fil-forge/ucantone/ucan" "github.com/storacha/sprue/pkg/identity" - "github.com/storacha/sprue/pkg/internal/ipldutil" - "github.com/storacha/sprue/pkg/lib/errors" "github.com/storacha/sprue/pkg/store/agent" "go.uber.org/zap" ) -const InvalidInvocationErrorName = "InvalidInvocation" +const ( + InvalidInvocationErrorName = "InvalidInvocation" + ConclusionReceiptNotFoundErrorName = "ConclusionReceiptNotFound" +) + +var ErrConclusionReceiptNotFound = errors.New(ConclusionReceiptNotFoundErrorName, "conclusion receipt not found") -type ConclusionHandlerFunc func(context.Context, invocation.Invocation, receipt.AnyReceipt, server.InvocationContext) error +type ConclusionHandlerFunc func(context.Context, ucan.Invocation, ucan.Receipt, ucan.Container) error // ConclusionHandler is the definition of a handler for an invocation conclusion // - a receiver for a receipt attesting to an invocation result. type ConclusionHandler struct { - // Ability is the invoked ability this handler is expecting to receive + // Command is the invoked command this handler is expecting to receive // conclusions for. - Ability ucan.Ability + Command ucan.Command // Handler is the function that receives the conclusion for the invocation. Handler ConclusionHandlerFunc } -// WithUCANConcludeMethod registers the ucan/conclude handler. +// NewUCANConcludeHandler creates a handler for /ucan/conclude invocations. // This handler processes receipt conclusions from clients. -// When it receives an http/put receipt, it calls blob/accept on piri +// When it receives an /http/put receipt, it calls /blob/accept on piri // and stores the accept receipt for later retrieval. -func WithUCANConcludeMethod(id *identity.Identity, agentStore agent.Store, handlers map[ucan.Ability]ConclusionHandlerFunc, logger *zap.Logger) server.Option { - return server.WithServiceMethod( - ucancap.ConcludeAbility, - server.Provide( - ucancap.Conclude, - UCANConcludeHandler(id, agentStore, handlers, logger), - ), - ) -} +func NewUCANConcludeHandler(id *identity.Identity, agentStore agent.Store, handlers map[ucan.Command]ConclusionHandlerFunc, logger *zap.Logger) Handler { + log := logger.With(zap.String("handler", ucancaps.ConcludeCommand)) + log.Info("registered conclude handlers", zap.Stringers("commands", slices.Collect(maps.Keys(handlers)))) + return Handler{ + Capability: ucancaps.Conclude, + Handler: bindexec.NewHandler(func( + req *bindexec.Request[*ucancaps.ConcludeArguments], + res *bindexec.Response[*ucancaps.ConcludeOK], + ) error { + args := req.Task().BindArguments() + rcptRoot := args.Receipt -func UCANConcludeHandler(id *identity.Identity, agentStore agent.Store, handlers map[ucan.Ability]ConclusionHandlerFunc, logger *zap.Logger) server.HandlerFunc[ucancap.ConcludeCaveats, ucancap.ConcludeOk, failure.IPLDBuilderFailure] { - log := logger.With(zap.String("handler", ucancap.ConcludeAbility)) - log.Info("registered conclude handlers", zap.Strings("abilities", slices.Collect(maps.Keys(handlers)))) - return func(ctx context.Context, - cap ucan.Capability[ucancap.ConcludeCaveats], - inv invocation.Invocation, - iCtx server.InvocationContext, - ) (result.Result[ucancap.ConcludeOk, failure.IPLDBuilderFailure], fx.Effects, error) { - rcptRoot := cap.Nb().Receipt + log := log.With(zap.Stringer("receipt", rcptRoot)) - log := log.With(zap.Stringer("receipt", rcptRoot)) + log.Debug("concluding received receipt", zap.Stringer("receipt", rcptRoot)) - log.Debug("concluding received receipt", zap.String("receipt", rcptRoot.String())) - - // Read the concluded receipt from the invocation's attached blocks - anyReader := receipt.NewAnyReceiptReader(captypes.Converters...) - rcpt, err := anyReader.Read(rcptRoot, inv.Blocks()) - if err != nil { - log.Error("failed to read concluded receipt", zap.Error(err)) - return nil, nil, fmt.Errorf("reading receipt: %w", err) - } - - task, err := ipldutil.ToCID(rcpt.Ran().Link()) - if err != nil { - return nil, nil, err - } + var rcpt ucan.Receipt + if req.Metadata() != nil { + for _, r := range req.Metadata().Receipts() { + if r.Link() == rcptRoot { + rcpt = r + } + } + } + if rcpt == nil { + log.Warn("receipt not found in invocation metadata") + return res.SetFailure(ErrConclusionReceiptNotFound) + } + log = log.With(zap.Stringer("task", rcpt.Ran())) - // Get the invocation that the receipt is for - ranInv, ok := rcpt.Ran().Invocation() - if !ok { - inv, err := agentStore.GetInvocation(ctx, task) - if err != nil { - // If can not find task for this receipt there is nothing to do here, if - // it was a receipt for something we care about we would have an - // invocation recorded. - if errors.Is(err, agent.ErrInvocationNotFound) { - return result.Ok[ucancap.ConcludeOk, failure.IPLDBuilderFailure](ucancap.ConcludeOk{Time: time.Now()}), nil, nil + var ranInv ucan.Invocation + // check if the invocation was included in the invocation metadata + for _, inv := range req.Metadata().Invocations() { + if inv.Task().Link() == rcpt.Ran() { + ranInv = inv } - log.Error("failed to get invocation from agent store", zap.Error(err)) - return nil, nil, fmt.Errorf("getting invocation: %w", err) } - ranInv = inv - } - if len(ranInv.Capabilities()) == 0 { - log.Warn("invocation has no capabilities") - return nil, nil, errors.New(InvalidInvocationErrorName, "invocation has no capabilities") - } + // if not included in invocation, check our store + if ranInv == nil { + inv, err := agentStore.GetInvocation(req.Context(), rcpt.Ran()) + if err != nil { + // If can not find invocation for this receipt there is nothing to do + // here, if it was a receipt for something we care about we would have + // an invocation recorded. + if errors.Is(err, agent.ErrInvocationNotFound) { + return res.SetSuccess(&ucancaps.ConcludeOK{}) + } + log.Error("failed to get invocation from agent store", zap.Error(err)) + return fmt.Errorf("getting invocation: %w", err) + } + ranInv = inv + } - ability := ranInv.Capabilities()[0].Can() - log = log.With( - zap.Stringer("ran", ranInv.Link()), - zap.String("ability", ability), - ) - log.Debug("found invocation for conclusion") + log = log.With(zap.Stringer("command", ranInv.Command())) + log.Debug("found invocation for conclusion") - if handler, ok := handlers[ability]; ok { - err := handler(ctx, ranInv, rcpt, iCtx) - if err != nil { - log.Error("failed to conclude receipt", zap.Error(err)) - return nil, nil, fmt.Errorf("concluding %q: %w", ability, err) + if handler, ok := handlers[ranInv.Command()]; ok { + err := handler(req.Context(), ranInv, rcpt, req.Metadata()) + if err != nil { + log.Error("failed to conclude invocation", zap.Error(err)) + return fmt.Errorf("concluding %q: %w", ranInv.Command(), err) + } } - } - return result.Ok[ucancap.ConcludeOk, failure.IPLDBuilderFailure]( - ucancap.ConcludeOk{Time: time.Now()}, - ), nil, nil + return res.SetSuccess(&ucancaps.ConcludeOK{}) + }), } } diff --git a/pkg/service/handlers/ucan_conclude_blob_replica_transfer.go b/pkg/service/handlers/ucan_conclude_blob_replica_transfer.go index 3f4c434..41e3007 100644 --- a/pkg/service/handlers/ucan_conclude_blob_replica_transfer.go +++ b/pkg/service/handlers/ucan_conclude_blob_replica_transfer.go @@ -1,218 +1,218 @@ package handlers -import ( - "bytes" - "context" - "fmt" - - replicacaps "github.com/storacha/go-libstoracha/capabilities/blob/replica" - ucancaps "github.com/storacha/go-libstoracha/capabilities/ucan" - "github.com/storacha/go-libstoracha/digestutil" - "github.com/storacha/go-ucanto/core/delegation" - "github.com/storacha/go-ucanto/core/invocation" - "github.com/storacha/go-ucanto/core/ipld" - "github.com/storacha/go-ucanto/core/receipt" - "github.com/storacha/go-ucanto/core/result" - fdm "github.com/storacha/go-ucanto/core/result/failure/datamodel" - "github.com/storacha/go-ucanto/principal" - "github.com/storacha/go-ucanto/principal/ed25519/verifier" - "github.com/storacha/go-ucanto/server" - "github.com/storacha/go-ucanto/ucan" - "github.com/storacha/go-ucanto/validator" - "github.com/storacha/sprue/pkg/identity" - "github.com/storacha/sprue/pkg/internal/ipldutil" - "github.com/storacha/sprue/pkg/lib/errors" - "github.com/storacha/sprue/pkg/store/agent" - "github.com/storacha/sprue/pkg/store/replica" - "go.uber.org/zap" -) - -const ( - InvalidReplicaTransferArgsErrorName = "InvalidReplicaTransferArgs" - InvalidReplicaTransferCauseErrorName = "InvalidReplicaTransferCause" - UnknownReplicaAllocationErrorName = "UnknownReplicaAllocation" - ReplicaTransferArgMismatchErrorName = "ReplicaTransferArgMismatch" - ReplicaAllocationFailedErrorName = "ReplicaAllocationFailed" - InvalidTransferReceiptSignatureErrorName = "InvalidTransferReceiptSignature" -) - -var ErrInvalidTransferReceiptSignature = errors.New(InvalidTransferReceiptSignatureErrorName, "invalid transfer receipt signature") - -func NewBlobReplicaTransferConcludeHandler( - id *identity.Identity, - agentStore agent.Store, - replicaStore replica.Store, - logger *zap.Logger, -) ConclusionHandler { - log := logger.With( - zap.String("handler", ucancaps.ConcludeAbility), - zap.String("conclude", replicacaps.TransferAbility), - ) - return ConclusionHandler{ - Ability: replicacaps.TransferAbility, - Handler: func(ctx context.Context, transferTask invocation.Invocation, transferRcpt receipt.AnyReceipt, iCtx server.InvocationContext) error { - log := log.With(zap.Stringer("ran", transferRcpt.Ran().Link())) - log.Debug("handling conclude") - - var err error - transferCap := transferTask.Capabilities()[0] - transferMatch, err := replicacaps.Transfer.Match(validator.NewSource(transferCap, transferTask)) - if err != nil { - log.Warn("failed to match replica transfer parameters", zap.Error(err)) - return errors.New(InvalidReplicaTransferArgsErrorName, "invalid replica transfer parameters: %v", err) - } - transferArgs := transferMatch.Value().Nb() - log = log.With(zap.Stringer("allocation", transferArgs.Cause)) - - allocTaskLink, err := ipldutil.ToCID(transferArgs.Cause) - if err != nil { - return err - } - - allocTask, err := agentStore.GetInvocation(ctx, allocTaskLink) - if err != nil { - log.Error("failed to get replica allocation invocation", zap.Error(err)) - return fmt.Errorf("getting replica allocation invocation: %w", err) - } - - ok, err := ucan.VerifySignature(allocTask.Data(), id.Signer.Verifier()) - if err != nil { - log.Error("failed to verify replica allocation invocation signature", zap.Error(err)) - return fmt.Errorf("verifying replica allocation invocation signature: %w", err) - } - // shouldn't happen - we should only store invocations made by our service... - if !ok { - log.Warn("replica allocation invocation issued by unknown DID", zap.Stringer("issuer", allocTask.Issuer().DID())) - return errors.New(UnknownReplicaAllocationErrorName, "allocation was not issued by this service") - } - - if len(allocTask.Capabilities()) != 1 { - log.Warn("invalid replica allocation invocation: expected exactly 1 capability", zap.Int("capabilities", len(allocTask.Capabilities()))) - return errors.New(InvalidReplicaTransferCauseErrorName, "invalid replica allocation invocation: expected exactly 1 capability, got %d", len(allocTask.Capabilities())) - } - - allocCap := allocTask.Capabilities()[0] - allocMatch, err := replicacaps.Allocate.Match(validator.NewSource(allocCap, allocTask)) - if err != nil { - log.Warn("failed to match replica allocation parameters", zap.Error(err)) - return errors.New(InvalidReplicaTransferCauseErrorName, "invalid replica allocation parameters: %v", err) - } - allocArgs := allocMatch.Value().Nb() - log = log.With( - zap.Stringer("space", allocArgs.Space), - zap.Dict( - "blob", - zap.String("digest", digestutil.Format(allocArgs.Blob.Digest)), - zap.Uint64("size", allocArgs.Blob.Size), - ), - ) - - var executor principal.Verifier - if transferRcpt.Issuer() != nil { - executor, err = verifier.Parse(transferRcpt.Issuer().DID().String()) - } else { - executor, err = verifier.Parse(transferTask.Audience().DID().String()) - } - if err != nil { - log.Warn("failed to parse executor DID", zap.Error(err)) - return fmt.Errorf("parsing executor DID: %w", err) - } - log = log.With(zap.Stringer("executor", executor.DID())) - - if executor.DID() != allocTask.Audience().DID() { - log.Warn("transfer executor does not match replica allocation audience", zap.Stringer("expected", allocTask.Audience().DID())) - return errors.New(ReplicaTransferArgMismatchErrorName, "transfer executor does not match replica allocation audience") - } - - updateReplicaStatus := func(status replica.ReplicationStatus) error { - err = replicaStore.SetStatus(ctx, allocArgs.Space, allocArgs.Blob.Digest, executor.DID(), status) - if err != nil { - log.Error("failed to update replica status", zap.Error(err)) - return err - } - return nil - } - - // verify the receipt was signed by the executor - ok, err = transferRcpt.VerifySignature(executor) - if err != nil { - log.Error("failed to verify receipt signature", zap.Error(err)) - return fmt.Errorf("verifying receipt signature: %w", err) - } - if !ok { - log.Warn("invalid receipt signature", zap.Error(err)) - _ = updateReplicaStatus(replica.Failed) - return ErrInvalidTransferReceiptSignature - } - - // verify the executor has delegated capability - proofs := []delegation.Proof{delegation.FromDelegation(transferTask)} - proofs = append(proofs, transferRcpt.Proofs()...) - _, err = validator.Claim( - ctx, - replicacaps.Transfer, - proofs, - validator.NewClaimContext( - executor, - iCtx.CanIssue, - iCtx.ValidateAuthorization, - iCtx.ResolveProof, - iCtx.ParsePrincipal, - iCtx.ResolveDIDKey, - iCtx.ValidateTimeBounds, - iCtx.AuthorityProofs()..., - ), - ) - if err != nil { - log.Warn("failed to validate executor capability", zap.Error(err)) - _ = updateReplicaStatus(replica.Failed) - return fmt.Errorf("validating executor capability: %w", err) - } - - allocRcpt, err := agentStore.GetReceipt(ctx, allocTaskLink) - if err != nil { - log.Error("failed to get replica allocation receipt", zap.Error(err)) - return fmt.Errorf("getting replica allocation receipt: %w", err) - } - - err = result.MatchResultR1( - allocRcpt.Out(), - func(o ipld.Node) error { return nil }, - func(x ipld.Node) error { return fdm.Bind(x) }, - ) - // if the receipt for the allocation was in error we should not be - // receiving a conclude for the transfer - if err != nil { - log.Warn("replica allocation failed", zap.Error(err)) - _ = updateReplicaStatus(replica.Failed) - return errors.New(ReplicaAllocationFailedErrorName, "Allocation associated with this transfer has failed: %s", err.Error()) - } - - if !bytes.Equal(transferArgs.Blob.Digest, allocArgs.Blob.Digest) || - transferArgs.Blob.Size != allocArgs.Blob.Size || - transferArgs.Space != allocArgs.Space { - log.Warn("transfer parameters do not match allocation parameters") - _ = updateReplicaStatus(replica.Failed) - return errors.New(ReplicaTransferArgMismatchErrorName, "transfer parameters do not match allocation parameters") - } - - status := result.MatchResultR1( - transferRcpt.Out(), - func(o ipld.Node) replica.ReplicationStatus { - return replica.Transferred - }, - func(x ipld.Node) replica.ReplicationStatus { - log.Warn("replica transfer failed", zap.Error(fdm.Bind(x))) - return replica.Failed - }, - ) - - err = updateReplicaStatus(status) - if err != nil { - return fmt.Errorf("updating replica status: %w", err) - } - - return nil - }, - } -} +// import ( +// "bytes" +// "context" +// "fmt" + +// "github.com/fil-forge/ucantone/errors" +// replicacaps "github.com/storacha/go-libstoracha/capabilities/blob/replica" +// ucancaps "github.com/storacha/go-libstoracha/capabilities/ucan" +// "github.com/storacha/go-libstoracha/digestutil" +// "github.com/storacha/go-ucanto/core/delegation" +// "github.com/storacha/go-ucanto/core/invocation" +// "github.com/storacha/go-ucanto/core/ipld" +// "github.com/storacha/go-ucanto/core/receipt" +// "github.com/storacha/go-ucanto/core/result" +// fdm "github.com/storacha/go-ucanto/core/result/failure/datamodel" +// "github.com/storacha/go-ucanto/principal" +// "github.com/storacha/go-ucanto/principal/ed25519/verifier" +// "github.com/storacha/go-ucanto/server" +// "github.com/storacha/go-ucanto/ucan" +// "github.com/storacha/go-ucanto/validator" +// "github.com/storacha/sprue/pkg/identity" +// "github.com/storacha/sprue/pkg/internal/ipldutil" +// "github.com/storacha/sprue/pkg/store/agent" +// "github.com/storacha/sprue/pkg/store/replica" +// "go.uber.org/zap" +// ) + +// const ( +// InvalidReplicaTransferArgsErrorName = "InvalidReplicaTransferArgs" +// InvalidReplicaTransferCauseErrorName = "InvalidReplicaTransferCause" +// UnknownReplicaAllocationErrorName = "UnknownReplicaAllocation" +// ReplicaTransferArgMismatchErrorName = "ReplicaTransferArgMismatch" +// ReplicaAllocationFailedErrorName = "ReplicaAllocationFailed" +// InvalidTransferReceiptSignatureErrorName = "InvalidTransferReceiptSignature" +// ) + +// var ErrInvalidTransferReceiptSignature = errors.New(InvalidTransferReceiptSignatureErrorName, "invalid transfer receipt signature") + +// func NewBlobReplicaTransferConcludeHandler( +// id *identity.Identity, +// agentStore agent.Store, +// replicaStore replica.Store, +// logger *zap.Logger, +// ) ConclusionHandler { +// log := logger.With( +// zap.String("handler", ucancaps.ConcludeAbility), +// zap.String("conclude", replicacaps.TransferAbility), +// ) +// return ConclusionHandler{ +// Ability: replicacaps.TransferAbility, +// Handler: func(ctx context.Context, transferTask invocation.Invocation, transferRcpt receipt.AnyReceipt, iCtx server.InvocationContext) error { +// log := log.With(zap.Stringer("ran", transferRcpt.Ran().Link())) +// log.Debug("handling conclude") + +// var err error +// transferCap := transferTask.Capabilities()[0] +// transferMatch, err := replicacaps.Transfer.Match(validator.NewSource(transferCap, transferTask)) +// if err != nil { +// log.Warn("failed to match replica transfer parameters", zap.Error(err)) +// return errors.New(InvalidReplicaTransferArgsErrorName, "invalid replica transfer parameters: %v", err) +// } +// transferArgs := transferMatch.Value().Nb() +// log = log.With(zap.Stringer("allocation", transferArgs.Cause)) + +// allocTaskLink, err := ipldutil.ToCID(transferArgs.Cause) +// if err != nil { +// return err +// } + +// allocTask, err := agentStore.GetInvocation(ctx, allocTaskLink) +// if err != nil { +// log.Error("failed to get replica allocation invocation", zap.Error(err)) +// return fmt.Errorf("getting replica allocation invocation: %w", err) +// } + +// ok, err := ucan.VerifySignature(allocTask.Data(), id.Signer.Verifier()) +// if err != nil { +// log.Error("failed to verify replica allocation invocation signature", zap.Error(err)) +// return fmt.Errorf("verifying replica allocation invocation signature: %w", err) +// } +// // shouldn't happen - we should only store invocations made by our service... +// if !ok { +// log.Warn("replica allocation invocation issued by unknown DID", zap.Stringer("issuer", allocTask.Issuer().DID())) +// return errors.New(UnknownReplicaAllocationErrorName, "allocation was not issued by this service") +// } + +// if len(allocTask.Capabilities()) != 1 { +// log.Warn("invalid replica allocation invocation: expected exactly 1 capability", zap.Int("capabilities", len(allocTask.Capabilities()))) +// return errors.New(InvalidReplicaTransferCauseErrorName, "invalid replica allocation invocation: expected exactly 1 capability, got %d", len(allocTask.Capabilities())) +// } + +// allocCap := allocTask.Capabilities()[0] +// allocMatch, err := replicacaps.Allocate.Match(validator.NewSource(allocCap, allocTask)) +// if err != nil { +// log.Warn("failed to match replica allocation parameters", zap.Error(err)) +// return errors.New(InvalidReplicaTransferCauseErrorName, "invalid replica allocation parameters: %v", err) +// } +// allocArgs := allocMatch.Value().Nb() +// log = log.With( +// zap.Stringer("space", allocArgs.Space), +// zap.Dict( +// "blob", +// zap.String("digest", digestutil.Format(allocArgs.Blob.Digest)), +// zap.Uint64("size", allocArgs.Blob.Size), +// ), +// ) + +// var executor principal.Verifier +// if transferRcpt.Issuer() != nil { +// executor, err = verifier.Parse(transferRcpt.Issuer().DID().String()) +// } else { +// executor, err = verifier.Parse(transferTask.Audience().DID().String()) +// } +// if err != nil { +// log.Warn("failed to parse executor DID", zap.Error(err)) +// return fmt.Errorf("parsing executor DID: %w", err) +// } +// log = log.With(zap.Stringer("executor", executor.DID())) + +// if executor.DID() != allocTask.Audience().DID() { +// log.Warn("transfer executor does not match replica allocation audience", zap.Stringer("expected", allocTask.Audience().DID())) +// return errors.New(ReplicaTransferArgMismatchErrorName, "transfer executor does not match replica allocation audience") +// } + +// updateReplicaStatus := func(status replica.ReplicationStatus) error { +// err = replicaStore.SetStatus(ctx, allocArgs.Space, allocArgs.Blob.Digest, executor.DID(), status) +// if err != nil { +// log.Error("failed to update replica status", zap.Error(err)) +// return err +// } +// return nil +// } + +// // verify the receipt was signed by the executor +// ok, err = transferRcpt.VerifySignature(executor) +// if err != nil { +// log.Error("failed to verify receipt signature", zap.Error(err)) +// return fmt.Errorf("verifying receipt signature: %w", err) +// } +// if !ok { +// log.Warn("invalid receipt signature", zap.Error(err)) +// _ = updateReplicaStatus(replica.Failed) +// return ErrInvalidTransferReceiptSignature +// } + +// // verify the executor has delegated capability +// proofs := []delegation.Proof{delegation.FromDelegation(transferTask)} +// proofs = append(proofs, transferRcpt.Proofs()...) +// _, err = validator.Claim( +// ctx, +// replicacaps.Transfer, +// proofs, +// validator.NewClaimContext( +// executor, +// iCtx.CanIssue, +// iCtx.ValidateAuthorization, +// iCtx.ResolveProof, +// iCtx.ParsePrincipal, +// iCtx.ResolveDIDKey, +// iCtx.ValidateTimeBounds, +// iCtx.AuthorityProofs()..., +// ), +// ) +// if err != nil { +// log.Warn("failed to validate executor capability", zap.Error(err)) +// _ = updateReplicaStatus(replica.Failed) +// return fmt.Errorf("validating executor capability: %w", err) +// } + +// allocRcpt, err := agentStore.GetReceipt(ctx, allocTaskLink) +// if err != nil { +// log.Error("failed to get replica allocation receipt", zap.Error(err)) +// return fmt.Errorf("getting replica allocation receipt: %w", err) +// } + +// err = result.MatchResultR1( +// allocRcpt.Out(), +// func(o ipld.Node) error { return nil }, +// func(x ipld.Node) error { return fdm.Bind(x) }, +// ) +// // if the receipt for the allocation was in error we should not be +// // receiving a conclude for the transfer +// if err != nil { +// log.Warn("replica allocation failed", zap.Error(err)) +// _ = updateReplicaStatus(replica.Failed) +// return errors.New(ReplicaAllocationFailedErrorName, "Allocation associated with this transfer has failed: %s", err.Error()) +// } + +// if !bytes.Equal(transferArgs.Blob.Digest, allocArgs.Blob.Digest) || +// transferArgs.Blob.Size != allocArgs.Blob.Size || +// transferArgs.Space != allocArgs.Space { +// log.Warn("transfer parameters do not match allocation parameters") +// _ = updateReplicaStatus(replica.Failed) +// return errors.New(ReplicaTransferArgMismatchErrorName, "transfer parameters do not match allocation parameters") +// } + +// status := result.MatchResultR1( +// transferRcpt.Out(), +// func(o ipld.Node) replica.ReplicationStatus { +// return replica.Transferred +// }, +// func(x ipld.Node) replica.ReplicationStatus { +// log.Warn("replica transfer failed", zap.Error(fdm.Bind(x))) +// return replica.Failed +// }, +// ) + +// err = updateReplicaStatus(status) +// if err != nil { +// return fmt.Errorf("updating replica status: %w", err) +// } + +// return nil +// }, +// } +// } diff --git a/pkg/service/handlers/ucan_conclude_blob_replica_transfer_test.go b/pkg/service/handlers/ucan_conclude_blob_replica_transfer_test.go index 0f4bf77..6bf1d74 100644 --- a/pkg/service/handlers/ucan_conclude_blob_replica_transfer_test.go +++ b/pkg/service/handlers/ucan_conclude_blob_replica_transfer_test.go @@ -1,352 +1,352 @@ package handlers_test -import ( - "io" - "testing" - - cidlink "github.com/ipld/go-ipld-prime/linking/cid" - replicacaps "github.com/storacha/go-libstoracha/capabilities/blob/replica" - "github.com/storacha/go-libstoracha/capabilities/types" - "github.com/storacha/go-ucanto/core/car" - "github.com/storacha/go-ucanto/core/delegation" - "github.com/storacha/go-ucanto/core/invocation" - "github.com/storacha/go-ucanto/core/ipld" - "github.com/storacha/go-ucanto/core/message" - "github.com/storacha/go-ucanto/core/receipt" - "github.com/storacha/go-ucanto/core/receipt/ran" - "github.com/storacha/go-ucanto/core/result" - "github.com/storacha/go-ucanto/core/result/ok" - "github.com/storacha/go-ucanto/server" - "github.com/storacha/go-ucanto/ucan" - "github.com/storacha/sprue/internal/testutil" - "github.com/storacha/sprue/pkg/identity" - "github.com/storacha/sprue/pkg/service/handlers" - "github.com/storacha/sprue/pkg/store/agent" - agent_store "github.com/storacha/sprue/pkg/store/agent/memory" - "github.com/storacha/sprue/pkg/store/replica" - replica_store "github.com/storacha/sprue/pkg/store/replica/memory" - "github.com/stretchr/testify/require" - "go.uber.org/zap/zaptest" -) - -// writeAgentMessage writes invocations and receipts to the agent store using -// the same pattern as the production writeAgentMessage helper. -func writeAgentMessage(t *testing.T, store agent.Store, invocations []invocation.Invocation, receipts []receipt.AnyReceipt) { - t.Helper() - msg, err := message.Build(invocations, receipts) - require.NoError(t, err) - - var idx []agent.IndexEntry - for entry, err := range agent.Index(msg) { - require.NoError(t, err) - idx = append(idx, entry) - } - - src, err := io.ReadAll(car.Encode([]ipld.Link{msg.Root().Link()}, msg.Blocks())) - require.NoError(t, err) - - err = store.Write(t.Context(), msg, idx, src) - require.NoError(t, err) -} - -func mockInvocationContext(t *testing.T) server.InvocationContext { - t.Helper() - s, err := server.NewServer(testutil.RandomSigner(t)) - require.NoError(t, err) - return s.Context() -} - -func TestBlobReplicaTransferConcludeHandler(t *testing.T) { - logger := zaptest.NewLogger(t) - ctx := t.Context() - - uploadService := testutil.WebService - storageProvider := testutil.RandomSigner(t) - - t.Run("invalid transfer parameters", func(t *testing.T) { - agentStore := agent_store.New() - replicaStore := replica_store.New() - - ch := handlers.NewBlobReplicaTransferConcludeHandler( - &identity.Identity{Signer: uploadService}, agentStore, replicaStore, logger, - ) - - // Create an invocation with wrong capability (not blob/replica/transfer) - cap := ucan.NewCapability( - "blob/allocate", - storageProvider.DID().String(), - ucan.NoCaveats{}, - ) - transferInv, err := invocation.Invoke(storageProvider, uploadService, cap) - require.NoError(t, err) - - transferRcpt, err := receipt.Issue( - storageProvider, - result.Ok[ok.Unit, ipld.Builder](ok.Unit{}), - ran.FromInvocation(transferInv), - ) - require.NoError(t, err) - - err = ch.Handler(ctx, transferInv, transferRcpt, nil) - require.Error(t, err) - require.Contains(t, err.Error(), "invalid replica transfer parameters") - }) - - t.Run("allocation invocation not found", func(t *testing.T) { - agentStore := agent_store.New() - replicaStore := replica_store.New() - - ch := handlers.NewBlobReplicaTransferConcludeHandler( - &identity.Identity{Signer: uploadService}, agentStore, replicaStore, logger, - ) - - space := testutil.RandomSigner(t) - digest := testutil.RandomMultihash(t) - blob := types.Blob{Digest: digest, Size: 1024} - site := cidlink.Link{Cid: testutil.RandomCID(t)} - // cause points to a non-existent allocation invocation - cause := cidlink.Link{Cid: testutil.RandomCID(t)} - - transferCap := ucan.NewCapability( - replicacaps.TransferAbility, - storageProvider.DID().String(), - replicacaps.TransferCaveats{ - Space: space.DID(), - Blob: blob, - Site: site, - Cause: cause, - }, - ) - transferInv, err := invocation.Invoke(storageProvider, uploadService, transferCap) - require.NoError(t, err) - - transferRcpt, err := receipt.Issue( - storageProvider, - result.Ok[ok.Unit, ipld.Builder](ok.Unit{}), - ran.FromInvocation(transferInv), - ) - require.NoError(t, err) - - err = ch.Handler(ctx, transferInv, transferRcpt, nil) - require.Error(t, err) - require.Contains(t, err.Error(), "getting replica allocation invocation") - }) - - t.Run("allocation not signed by service", func(t *testing.T) { - agentStore := agent_store.New() - replicaStore := replica_store.New() - - ch := handlers.NewBlobReplicaTransferConcludeHandler( - &identity.Identity{Signer: uploadService}, agentStore, replicaStore, logger, - ) - - space := testutil.RandomSigner(t) - digest := testutil.RandomMultihash(t) - blob := types.Blob{Digest: digest, Size: 1024} - site := cidlink.Link{Cid: testutil.RandomCID(t)} - replicateCause := cidlink.Link{Cid: testutil.RandomCID(t)} - - // Create allocation invocation signed by someone OTHER than uploadService - imposter := testutil.RandomSigner(t) - allocCap := ucan.NewCapability( - replicacaps.AllocateAbility, - storageProvider.DID().String(), - replicacaps.AllocateCaveats{ - Space: space.DID(), - Blob: blob, - Site: site, - Cause: replicateCause, - }, - ) - allocInv, err := invocation.Invoke(imposter, storageProvider, allocCap) - require.NoError(t, err) - - allocRcpt, err := receipt.Issue( - storageProvider, - result.Ok[ok.Unit, ipld.Builder](ok.Unit{}), - ran.FromInvocation(allocInv), - ) - require.NoError(t, err) - - // Store the allocation in the agent store - writeAgentMessage(t, agentStore, []invocation.Invocation{allocInv}, []receipt.AnyReceipt{allocRcpt}) - - // Create transfer invocation referencing the allocation - transferCap := ucan.NewCapability( - replicacaps.TransferAbility, - storageProvider.DID().String(), - replicacaps.TransferCaveats{ - Space: space.DID(), - Blob: blob, - Site: site, - Cause: allocInv.Link(), - }, - ) - transferInv, err := invocation.Invoke(storageProvider, uploadService, transferCap) - require.NoError(t, err) - - transferRcpt, err := receipt.Issue( - storageProvider, - result.Ok[ok.Unit, ipld.Builder](ok.Unit{}), - ran.FromInvocation(transferInv), - ) - require.NoError(t, err) - - err = ch.Handler(ctx, transferInv, transferRcpt, nil) - require.Error(t, err) - require.Contains(t, err.Error(), "allocation was not issued by this service") - }) - - t.Run("success updates replica status to transferred", func(t *testing.T) { - agentStore := agent_store.New() - replicaStore := replica_store.New() - - ch := handlers.NewBlobReplicaTransferConcludeHandler( - &identity.Identity{Signer: uploadService}, agentStore, replicaStore, logger, - ) - - space := testutil.RandomSigner(t) - digest := testutil.RandomMultihash(t) - blob := types.Blob{Digest: digest, Size: 1024} - site := cidlink.Link{Cid: testutil.RandomCID(t)} - replicateCause := cidlink.Link{Cid: testutil.RandomCID(t)} - - // storageProvider delegates to uploadService so it can invoke allocate - allocProof, err := delegation.Delegate( - storageProvider, uploadService, - []ucan.Capability[ucan.NoCaveats]{ - ucan.NewCapability( - replicacaps.AllocateAbility, - storageProvider.DID().String(), - ucan.NoCaveats{}, - ), - }, - ) - require.NoError(t, err) - - // uploadService invokes allocate on storageProvider with the delegation as proof - allocInv, err := replicacaps.Allocate.Invoke( - uploadService, storageProvider, - storageProvider.DID().String(), - replicacaps.AllocateCaveats{ - Space: space.DID(), - Blob: blob, - Site: site, - Cause: replicateCause, - }, - delegation.WithProof(delegation.FromDelegation(allocProof)), - ) - require.NoError(t, err) - - allocRcpt, err := receipt.Issue( - storageProvider, - result.Ok[ok.Unit, ipld.Builder](ok.Unit{}), - ran.FromInvocation(allocInv), - ) - require.NoError(t, err) - - writeAgentMessage(t, agentStore, []invocation.Invocation{allocInv}, []receipt.AnyReceipt{allocRcpt}) - - // Add a replica record so SetStatus succeeds - err = replicaStore.Add(ctx, space.DID(), digest, storageProvider.DID(), replica.Allocated, testutil.RandomCID(t)) - require.NoError(t, err) - - // storageProvider self-issues the transfer invocation - transferInv, err := replicacaps.Transfer.Invoke( - storageProvider, storageProvider, - storageProvider.DID().String(), - replicacaps.TransferCaveats{ - Space: space.DID(), - Blob: blob, - Site: site, - Cause: allocInv.Link(), - }, - ) - require.NoError(t, err) - - // Receipt signed by storageProvider (the executor) - transferRcpt, err := receipt.Issue( - storageProvider, - result.Ok[ok.Unit, ipld.Builder](ok.Unit{}), - ran.FromInvocation(transferInv), - ) - require.NoError(t, err) - - iCtx := mockInvocationContext(t) - err = ch.Handler(ctx, transferInv, transferRcpt, iCtx) - require.NoError(t, err) - - // Verify the replica status was updated to Transferred - records, err := replicaStore.List(ctx, space.DID(), digest) - require.NoError(t, err) - require.Len(t, records, 1) - require.Equal(t, replica.Transferred, records[0].Status) - }) - - t.Run("executor mismatch", func(t *testing.T) { - agentStore := agent_store.New() - replicaStore := replica_store.New() - - ch := handlers.NewBlobReplicaTransferConcludeHandler( - &identity.Identity{Signer: uploadService}, agentStore, replicaStore, logger, - ) - - space := testutil.RandomSigner(t) - digest := testutil.RandomMultihash(t) - blob := types.Blob{Digest: digest, Size: 1024} - site := cidlink.Link{Cid: testutil.RandomCID(t)} - replicateCause := cidlink.Link{Cid: testutil.RandomCID(t)} - - // Create allocation invocation signed by uploadService, audience = storageProvider - allocCap := ucan.NewCapability( - replicacaps.AllocateAbility, - storageProvider.DID().String(), - replicacaps.AllocateCaveats{ - Space: space.DID(), - Blob: blob, - Site: site, - Cause: replicateCause, - }, - ) - allocInv, err := invocation.Invoke(uploadService, storageProvider, allocCap) - require.NoError(t, err) - - allocRcpt, err := receipt.Issue( - storageProvider, - result.Ok[ok.Unit, ipld.Builder](ok.Unit{}), - ran.FromInvocation(allocInv), - ) - require.NoError(t, err) - - writeAgentMessage(t, agentStore, []invocation.Invocation{allocInv}, []receipt.AnyReceipt{allocRcpt}) - - // Create transfer invocation from a DIFFERENT provider than the allocation audience - otherProvider := testutil.RandomSigner(t) - transferCap := ucan.NewCapability( - replicacaps.TransferAbility, - otherProvider.DID().String(), - replicacaps.TransferCaveats{ - Space: space.DID(), - Blob: blob, - Site: site, - Cause: allocInv.Link(), - }, - ) - // audience is otherProvider (different from storageProvider who is alloc audience) - transferInv, err := invocation.Invoke(uploadService, otherProvider, transferCap) - require.NoError(t, err) - - // receipt issued by otherProvider - transferRcpt, err := receipt.Issue( - otherProvider, - result.Ok[ok.Unit, ipld.Builder](ok.Unit{}), - ran.FromInvocation(transferInv), - ) - require.NoError(t, err) - - err = ch.Handler(ctx, transferInv, transferRcpt, nil) - require.Error(t, err) - require.Contains(t, err.Error(), "transfer executor does not match replica allocation audience") - }) - -} +// import ( +// "io" +// "testing" + +// cidlink "github.com/ipld/go-ipld-prime/linking/cid" +// replicacaps "github.com/storacha/go-libstoracha/capabilities/blob/replica" +// "github.com/storacha/go-libstoracha/capabilities/types" +// "github.com/storacha/go-ucanto/core/car" +// "github.com/storacha/go-ucanto/core/delegation" +// "github.com/storacha/go-ucanto/core/invocation" +// "github.com/storacha/go-ucanto/core/ipld" +// "github.com/storacha/go-ucanto/core/message" +// "github.com/storacha/go-ucanto/core/receipt" +// "github.com/storacha/go-ucanto/core/receipt/ran" +// "github.com/storacha/go-ucanto/core/result" +// "github.com/storacha/go-ucanto/core/result/ok" +// "github.com/storacha/go-ucanto/server" +// "github.com/storacha/go-ucanto/ucan" +// "github.com/storacha/sprue/internal/testutil" +// "github.com/storacha/sprue/pkg/identity" +// "github.com/storacha/sprue/pkg/service/handlers" +// "github.com/storacha/sprue/pkg/store/agent" +// agent_store "github.com/storacha/sprue/pkg/store/agent/memory" +// "github.com/storacha/sprue/pkg/store/replica" +// replica_store "github.com/storacha/sprue/pkg/store/replica/memory" +// "github.com/stretchr/testify/require" +// "go.uber.org/zap/zaptest" +// ) + +// // writeAgentMessage writes invocations and receipts to the agent store using +// // the same pattern as the production writeAgentMessage helper. +// func writeAgentMessage(t *testing.T, store agent.Store, invocations []invocation.Invocation, receipts []receipt.AnyReceipt) { +// t.Helper() +// msg, err := message.Build(invocations, receipts) +// require.NoError(t, err) + +// var idx []agent.IndexEntry +// for entry, err := range agent.Index(msg) { +// require.NoError(t, err) +// idx = append(idx, entry) +// } + +// src, err := io.ReadAll(car.Encode([]ipld.Link{msg.Root().Link()}, msg.Blocks())) +// require.NoError(t, err) + +// err = store.Write(t.Context(), msg, idx, src) +// require.NoError(t, err) +// } + +// func mockInvocationContext(t *testing.T) server.InvocationContext { +// t.Helper() +// s, err := server.NewServer(testutil.RandomSigner(t)) +// require.NoError(t, err) +// return s.Context() +// } + +// func TestBlobReplicaTransferConcludeHandler(t *testing.T) { +// logger := zaptest.NewLogger(t) +// ctx := t.Context() + +// uploadService := testutil.WebService +// storageProvider := testutil.RandomSigner(t) + +// t.Run("invalid transfer parameters", func(t *testing.T) { +// agentStore := agent_store.New() +// replicaStore := replica_store.New() + +// ch := handlers.NewBlobReplicaTransferConcludeHandler( +// &identity.Identity{Signer: uploadService}, agentStore, replicaStore, logger, +// ) + +// // Create an invocation with wrong capability (not blob/replica/transfer) +// cap := ucan.NewCapability( +// "blob/allocate", +// storageProvider.DID().String(), +// ucan.NoCaveats{}, +// ) +// transferInv, err := invocation.Invoke(storageProvider, uploadService, cap) +// require.NoError(t, err) + +// transferRcpt, err := receipt.Issue( +// storageProvider, +// result.Ok[ok.Unit, ipld.Builder](ok.Unit{}), +// ran.FromInvocation(transferInv), +// ) +// require.NoError(t, err) + +// err = ch.Handler(ctx, transferInv, transferRcpt, nil) +// require.Error(t, err) +// require.Contains(t, err.Error(), "invalid replica transfer parameters") +// }) + +// t.Run("allocation invocation not found", func(t *testing.T) { +// agentStore := agent_store.New() +// replicaStore := replica_store.New() + +// ch := handlers.NewBlobReplicaTransferConcludeHandler( +// &identity.Identity{Signer: uploadService}, agentStore, replicaStore, logger, +// ) + +// space := testutil.RandomSigner(t) +// digest := testutil.RandomMultihash(t) +// blob := types.Blob{Digest: digest, Size: 1024} +// site := cidlink.Link{Cid: testutil.RandomCID(t)} +// // cause points to a non-existent allocation invocation +// cause := cidlink.Link{Cid: testutil.RandomCID(t)} + +// transferCap := ucan.NewCapability( +// replicacaps.TransferAbility, +// storageProvider.DID().String(), +// replicacaps.TransferCaveats{ +// Space: space.DID(), +// Blob: blob, +// Site: site, +// Cause: cause, +// }, +// ) +// transferInv, err := invocation.Invoke(storageProvider, uploadService, transferCap) +// require.NoError(t, err) + +// transferRcpt, err := receipt.Issue( +// storageProvider, +// result.Ok[ok.Unit, ipld.Builder](ok.Unit{}), +// ran.FromInvocation(transferInv), +// ) +// require.NoError(t, err) + +// err = ch.Handler(ctx, transferInv, transferRcpt, nil) +// require.Error(t, err) +// require.Contains(t, err.Error(), "getting replica allocation invocation") +// }) + +// t.Run("allocation not signed by service", func(t *testing.T) { +// agentStore := agent_store.New() +// replicaStore := replica_store.New() + +// ch := handlers.NewBlobReplicaTransferConcludeHandler( +// &identity.Identity{Signer: uploadService}, agentStore, replicaStore, logger, +// ) + +// space := testutil.RandomSigner(t) +// digest := testutil.RandomMultihash(t) +// blob := types.Blob{Digest: digest, Size: 1024} +// site := cidlink.Link{Cid: testutil.RandomCID(t)} +// replicateCause := cidlink.Link{Cid: testutil.RandomCID(t)} + +// // Create allocation invocation signed by someone OTHER than uploadService +// imposter := testutil.RandomSigner(t) +// allocCap := ucan.NewCapability( +// replicacaps.AllocateAbility, +// storageProvider.DID().String(), +// replicacaps.AllocateCaveats{ +// Space: space.DID(), +// Blob: blob, +// Site: site, +// Cause: replicateCause, +// }, +// ) +// allocInv, err := invocation.Invoke(imposter, storageProvider, allocCap) +// require.NoError(t, err) + +// allocRcpt, err := receipt.Issue( +// storageProvider, +// result.Ok[ok.Unit, ipld.Builder](ok.Unit{}), +// ran.FromInvocation(allocInv), +// ) +// require.NoError(t, err) + +// // Store the allocation in the agent store +// writeAgentMessage(t, agentStore, []invocation.Invocation{allocInv}, []receipt.AnyReceipt{allocRcpt}) + +// // Create transfer invocation referencing the allocation +// transferCap := ucan.NewCapability( +// replicacaps.TransferAbility, +// storageProvider.DID().String(), +// replicacaps.TransferCaveats{ +// Space: space.DID(), +// Blob: blob, +// Site: site, +// Cause: allocInv.Link(), +// }, +// ) +// transferInv, err := invocation.Invoke(storageProvider, uploadService, transferCap) +// require.NoError(t, err) + +// transferRcpt, err := receipt.Issue( +// storageProvider, +// result.Ok[ok.Unit, ipld.Builder](ok.Unit{}), +// ran.FromInvocation(transferInv), +// ) +// require.NoError(t, err) + +// err = ch.Handler(ctx, transferInv, transferRcpt, nil) +// require.Error(t, err) +// require.Contains(t, err.Error(), "allocation was not issued by this service") +// }) + +// t.Run("success updates replica status to transferred", func(t *testing.T) { +// agentStore := agent_store.New() +// replicaStore := replica_store.New() + +// ch := handlers.NewBlobReplicaTransferConcludeHandler( +// &identity.Identity{Signer: uploadService}, agentStore, replicaStore, logger, +// ) + +// space := testutil.RandomSigner(t) +// digest := testutil.RandomMultihash(t) +// blob := types.Blob{Digest: digest, Size: 1024} +// site := cidlink.Link{Cid: testutil.RandomCID(t)} +// replicateCause := cidlink.Link{Cid: testutil.RandomCID(t)} + +// // storageProvider delegates to uploadService so it can invoke allocate +// allocProof, err := delegation.Delegate( +// storageProvider, uploadService, +// []ucan.Capability[ucan.NoCaveats]{ +// ucan.NewCapability( +// replicacaps.AllocateAbility, +// storageProvider.DID().String(), +// ucan.NoCaveats{}, +// ), +// }, +// ) +// require.NoError(t, err) + +// // uploadService invokes allocate on storageProvider with the delegation as proof +// allocInv, err := replicacaps.Allocate.Invoke( +// uploadService, storageProvider, +// storageProvider.DID().String(), +// replicacaps.AllocateCaveats{ +// Space: space.DID(), +// Blob: blob, +// Site: site, +// Cause: replicateCause, +// }, +// delegation.WithProof(delegation.FromDelegation(allocProof)), +// ) +// require.NoError(t, err) + +// allocRcpt, err := receipt.Issue( +// storageProvider, +// result.Ok[ok.Unit, ipld.Builder](ok.Unit{}), +// ran.FromInvocation(allocInv), +// ) +// require.NoError(t, err) + +// writeAgentMessage(t, agentStore, []invocation.Invocation{allocInv}, []receipt.AnyReceipt{allocRcpt}) + +// // Add a replica record so SetStatus succeeds +// err = replicaStore.Add(ctx, space.DID(), digest, storageProvider.DID(), replica.Allocated, testutil.RandomCID(t)) +// require.NoError(t, err) + +// // storageProvider self-issues the transfer invocation +// transferInv, err := replicacaps.Transfer.Invoke( +// storageProvider, storageProvider, +// storageProvider.DID().String(), +// replicacaps.TransferCaveats{ +// Space: space.DID(), +// Blob: blob, +// Site: site, +// Cause: allocInv.Link(), +// }, +// ) +// require.NoError(t, err) + +// // Receipt signed by storageProvider (the executor) +// transferRcpt, err := receipt.Issue( +// storageProvider, +// result.Ok[ok.Unit, ipld.Builder](ok.Unit{}), +// ran.FromInvocation(transferInv), +// ) +// require.NoError(t, err) + +// iCtx := mockInvocationContext(t) +// err = ch.Handler(ctx, transferInv, transferRcpt, iCtx) +// require.NoError(t, err) + +// // Verify the replica status was updated to Transferred +// records, err := replicaStore.List(ctx, space.DID(), digest) +// require.NoError(t, err) +// require.Len(t, records, 1) +// require.Equal(t, replica.Transferred, records[0].Status) +// }) + +// t.Run("executor mismatch", func(t *testing.T) { +// agentStore := agent_store.New() +// replicaStore := replica_store.New() + +// ch := handlers.NewBlobReplicaTransferConcludeHandler( +// &identity.Identity{Signer: uploadService}, agentStore, replicaStore, logger, +// ) + +// space := testutil.RandomSigner(t) +// digest := testutil.RandomMultihash(t) +// blob := types.Blob{Digest: digest, Size: 1024} +// site := cidlink.Link{Cid: testutil.RandomCID(t)} +// replicateCause := cidlink.Link{Cid: testutil.RandomCID(t)} + +// // Create allocation invocation signed by uploadService, audience = storageProvider +// allocCap := ucan.NewCapability( +// replicacaps.AllocateAbility, +// storageProvider.DID().String(), +// replicacaps.AllocateCaveats{ +// Space: space.DID(), +// Blob: blob, +// Site: site, +// Cause: replicateCause, +// }, +// ) +// allocInv, err := invocation.Invoke(uploadService, storageProvider, allocCap) +// require.NoError(t, err) + +// allocRcpt, err := receipt.Issue( +// storageProvider, +// result.Ok[ok.Unit, ipld.Builder](ok.Unit{}), +// ran.FromInvocation(allocInv), +// ) +// require.NoError(t, err) + +// writeAgentMessage(t, agentStore, []invocation.Invocation{allocInv}, []receipt.AnyReceipt{allocRcpt}) + +// // Create transfer invocation from a DIFFERENT provider than the allocation audience +// otherProvider := testutil.RandomSigner(t) +// transferCap := ucan.NewCapability( +// replicacaps.TransferAbility, +// otherProvider.DID().String(), +// replicacaps.TransferCaveats{ +// Space: space.DID(), +// Blob: blob, +// Site: site, +// Cause: allocInv.Link(), +// }, +// ) +// // audience is otherProvider (different from storageProvider who is alloc audience) +// transferInv, err := invocation.Invoke(uploadService, otherProvider, transferCap) +// require.NoError(t, err) + +// // receipt issued by otherProvider +// transferRcpt, err := receipt.Issue( +// otherProvider, +// result.Ok[ok.Unit, ipld.Builder](ok.Unit{}), +// ran.FromInvocation(transferInv), +// ) +// require.NoError(t, err) + +// err = ch.Handler(ctx, transferInv, transferRcpt, nil) +// require.Error(t, err) +// require.Contains(t, err.Error(), "transfer executor does not match replica allocation audience") +// }) + +// } diff --git a/pkg/service/handlers/ucan_conclude_http_put.go b/pkg/service/handlers/ucan_conclude_http_put.go index 903e9aa..2d739f6 100644 --- a/pkg/service/handlers/ucan_conclude_http_put.go +++ b/pkg/service/handlers/ucan_conclude_http_put.go @@ -4,19 +4,17 @@ import ( "context" "fmt" - "github.com/storacha/go-libstoracha/capabilities/blob" - "github.com/storacha/go-libstoracha/capabilities/http" - "github.com/storacha/go-libstoracha/capabilities/ucan" - "github.com/storacha/go-libstoracha/digestutil" - "github.com/storacha/go-ucanto/core/invocation" - "github.com/storacha/go-ucanto/core/ipld" - "github.com/storacha/go-ucanto/core/receipt" - "github.com/storacha/go-ucanto/core/result" - "github.com/storacha/go-ucanto/core/result/failure/datamodel" - "github.com/storacha/go-ucanto/server" - "github.com/storacha/go-ucanto/validator" - "github.com/storacha/sprue/pkg/internal/ipldutil" - "github.com/storacha/sprue/pkg/lib/errors" + blobcaps "github.com/fil-forge/libforge/capabilities/blob" + httpcaps "github.com/fil-forge/libforge/capabilities/http" + ucancaps "github.com/fil-forge/libforge/capabilities/ucan" + "github.com/fil-forge/libforge/digestutil" + "github.com/fil-forge/ucantone/errors" + edm "github.com/fil-forge/ucantone/errors/datamodel" + "github.com/fil-forge/ucantone/ipld" + "github.com/fil-forge/ucantone/ipld/datamodel" + "github.com/fil-forge/ucantone/result" + "github.com/fil-forge/ucantone/ucan" + "github.com/storacha/sprue/pkg/lib/ucan_server" "github.com/storacha/sprue/pkg/piriclient" "github.com/storacha/sprue/pkg/routing" "github.com/storacha/sprue/pkg/store/agent" @@ -32,49 +30,52 @@ func NewHTTPPutConcludeHandler( logger *zap.Logger, ) ConclusionHandler { log := logger.With( - zap.String("handler", ucan.ConcludeAbility), - zap.String("conclude", http.PutAbility), + zap.String("handler", ucancaps.ConcludeCommand), + zap.String("conclude", httpcaps.PutCommand), ) return ConclusionHandler{ - Ability: http.PutAbility, - Handler: func(ctx context.Context, putInv invocation.Invocation, putRcpt receipt.AnyReceipt, _ server.InvocationContext) error { - log := log.With(zap.Stringer("ran", putRcpt.Ran().Link())) + Command: httpcaps.PutCommand, + Handler: func(ctx context.Context, putInv ucan.Invocation, putRcpt ucan.Receipt, meta ucan.Container) error { + log := log.With(zap.Stringer("ran", putRcpt.Ran())) log.Debug("handling conclude") - var err error - putCap := putInv.Capabilities()[0] - putMatch, err := http.Put.Match(validator.NewSource(putCap, putInv)) + putArgs := httpcaps.PutArguments{} + err := datamodel.Rebind(datamodel.NewAny(putInv.Arguments()), &putArgs) if err != nil { - log.Error("failed to match http/put invocation", zap.Error(err)) - return fmt.Errorf("matching http/put invocation: %w", err) + log.Error("failed to rebind HTTP PUT arguments", zap.Error(err)) + return fmt.Errorf("rebinding HTTP PUT arguments: %w", err) } - allocateTaskLink, err := ipldutil.ToCID(putMatch.Value().Nb().URL.UcanAwait.Link) - if err != nil { - return err - } - log = log.With(zap.Stringer("allocation", allocateTaskLink)) + allocTaskLink := putArgs.Destination.Task + log = log.With(zap.Stringer("allocation", allocTaskLink)) - allocTask, err := agentStore.GetInvocation(ctx, allocateTaskLink) + allocInv, err := agentStore.GetInvocation(ctx, allocTaskLink) if err != nil { log.Error("failed to get allocation invocation", zap.Error(err)) return fmt.Errorf("getting allocation invocation: %w", err) } - log = log.With(zap.Stringer("provider", allocTask.Audience().DID())) - allocMatch, err := blob.Allocate.Match(validator.NewSource(allocTask.Capabilities()[0], allocTask)) - if err != nil { - log.Error("matching blob/allocate invocation", zap.Error(err)) - return fmt.Errorf("matching blob/allocate invocation: %w", err) + provider := allocInv.Audience() + if provider == nil { + // shouldn't happen, subject should be the space and audience the node + provider = allocInv.Subject() } + space := allocInv.Subject() - allocNb := allocMatch.Value().Nb() log = log.With( - zap.Stringer("space", allocNb.Space), - zap.String("digest", digestutil.Format(allocNb.Blob.Digest)), + zap.Stringer("space", space.DID()), + zap.Stringer("provider", provider.DID()), ) - info, err := router.GetProviderInfo(ctx, allocTask.Audience()) + allocArgs := blobcaps.AllocateArguments{} + err = datamodel.Rebind(datamodel.NewAny(allocInv.Arguments()), &allocArgs) + if err != nil { + log.Error("failed to rebind allocate arguments", zap.Error(err)) + return fmt.Errorf("rebinding allocate arguments: %w", err) + } + log = log.With(zap.String("digest", digestutil.Format(allocArgs.Blob.Digest))) + + info, err := router.GetProviderInfo(ctx, provider) if err != nil { log.Error("failed to get storage provider info", zap.Error(err)) return fmt.Errorf("getting storage provider info: %w", err) @@ -86,19 +87,20 @@ func NewHTTPPutConcludeHandler( return fmt.Errorf("creating client: %w", err) } - res, accTask, accRcpt, err := client.Accept(ctx, &piriclient.AcceptRequest{ - Space: allocNb.Space, - Digest: allocNb.Blob.Digest, - Size: allocNb.Blob.Size, + proofStore := ucan_server.NewContainerProofStore(meta) + res, accInv, accRcpt, err := client.Accept(ctx, &piriclient.AcceptRequest{ + Space: space.DID(), + Digest: allocArgs.Blob.Digest, + Size: allocArgs.Blob.Size, Put: putInv.Link(), - }, delegationFetcher{info.Proof}) + }, proofStore) if err != nil { - log.Error("failed to execute blob/accept", zap.Error(err)) - return fmt.Errorf("executing blob/accept: %w", err) + log.Error("failed to execute blob accept", zap.Error(err)) + return fmt.Errorf("executing blob accept: %w", err) } log = log.With(zap.Stringer("site", res.Site)) - err = writeAgentMessage(ctx, agentStore, []invocation.Invocation{accTask}, []receipt.AnyReceipt{accRcpt}) + err = writeAgentMessage(ctx, agentStore, []ucan.Invocation{accInv}, []ucan.Receipt{accRcpt}) if err != nil { log.Error("failed to write agent message", zap.Error(err)) return fmt.Errorf("writing agent message: %w", err) @@ -107,19 +109,25 @@ func NewHTTPPutConcludeHandler( // if accept task was not successful do not register the blob in the space return result.MatchResultR1( accRcpt.Out(), - func(o ipld.Node) error { + func(o ipld.Any) error { log.Debug("accept success") - err := blobRegistry.Register(ctx, allocNb.Space, allocNb.Blob, allocateTaskLink) + err := blobRegistry.Register(ctx, space.DID(), allocArgs.Blob, allocArgs.Cause) // it's ok if there's already a registration of this blob in this space if err != nil && !errors.Is(err, blobregistry.ErrEntryExists) { return err } return nil }, - func(x ipld.Node) error { - f := datamodel.Bind(x) - log.Error("failed blob/accept receipt", zap.Error(f)) - return f + func(x ipld.Any) error { + var model edm.ErrorModel + err := datamodel.Rebind(datamodel.NewAny(x), &model) + if err != nil { + log.Error("failed to bind execution failure", zap.Error(err)) + log.Error("failed execution", zap.Any("error", x)) + return fmt.Errorf("executing blob accept: %v", x) + } + log.Error("failed execution", zap.String("name", model.ErrorName), zap.Error(model)) + return fmt.Errorf("executing blob accept: %w", model) }, ) }, diff --git a/pkg/service/handlers/ucan_conclude_http_put_test.go b/pkg/service/handlers/ucan_conclude_http_put_test.go index fe5ee74..8f360e3 100644 --- a/pkg/service/handlers/ucan_conclude_http_put_test.go +++ b/pkg/service/handlers/ucan_conclude_http_put_test.go @@ -4,277 +4,231 @@ import ( "net/url" "testing" - cidlink "github.com/ipld/go-ipld-prime/linking/cid" - blobcap "github.com/storacha/go-libstoracha/capabilities/blob" - httpcap "github.com/storacha/go-libstoracha/capabilities/http" - "github.com/storacha/go-libstoracha/capabilities/types" - "github.com/storacha/go-ucanto/core/invocation" - "github.com/storacha/go-ucanto/core/ipld" - "github.com/storacha/go-ucanto/core/receipt" - "github.com/storacha/go-ucanto/core/receipt/ran" - "github.com/storacha/go-ucanto/core/result" - "github.com/storacha/go-ucanto/core/result/ok" - "github.com/storacha/go-ucanto/ucan" + blobcaps "github.com/fil-forge/libforge/capabilities/blob" + httpcaps "github.com/fil-forge/libforge/capabilities/http" + "github.com/fil-forge/libforge/didmailto" + "github.com/fil-forge/ucantone/ipld" + "github.com/fil-forge/ucantone/result" + "github.com/fil-forge/ucantone/ucan/container" + "github.com/fil-forge/ucantone/ucan/delegation" + "github.com/fil-forge/ucantone/ucan/invocation" + "github.com/fil-forge/ucantone/ucan/promise" + "github.com/fil-forge/ucantone/ucan/receipt" "github.com/storacha/sprue/internal/testutil" - "github.com/storacha/sprue/pkg/lib/didmailto" "github.com/storacha/sprue/pkg/piriclient" "github.com/storacha/sprue/pkg/routing" "github.com/storacha/sprue/pkg/service/handlers" + "github.com/storacha/sprue/pkg/store/agent" agent_store "github.com/storacha/sprue/pkg/store/agent/memory" + blob_registry "github.com/storacha/sprue/pkg/store/blob_registry/memory" + consumer_store "github.com/storacha/sprue/pkg/store/consumer/memory" + metrics_store "github.com/storacha/sprue/pkg/store/metrics/memory" + spacediff_store "github.com/storacha/sprue/pkg/store/space_diff/memory" storage_provider_store "github.com/storacha/sprue/pkg/store/storage_provider/memory" "github.com/stretchr/testify/require" + "go.uber.org/zap" "go.uber.org/zap/zaptest" ) +type httpPutDeps struct { + ch handlers.ConclusionHandler + spStore *storage_provider_store.Store + agentStore *agent_store.Store + consumerStore *consumer_store.Store + blobReg *blob_registry.Store +} + +func newHTTPPutDeps(t *testing.T, nodeProvider piriclient.Provider, logger *zap.Logger) *httpPutDeps { + t.Helper() + spStore := storage_provider_store.New() + router := routing.NewService(spStore, logger) + agentStore := agent_store.New() + consumerStore := consumer_store.New() + blobReg := blob_registry.New( + spacediff_store.New(), + consumerStore, + metrics_store.NewSpaceStore(), + metrics_store.New(), + ) + ch := handlers.NewHTTPPutConcludeHandler(router, nodeProvider, agentStore, blobReg, logger) + return &httpPutDeps{ + ch: ch, + spStore: spStore, + agentStore: agentStore, + consumerStore: consumerStore, + blobReg: blobReg, + } +} + func TestHTTPPutConcludeHandler(t *testing.T) { logger := zaptest.NewLogger(t) ctx := t.Context() uploadService := testutil.WebService - t.Run("invalid http/put parameters", func(t *testing.T) { - spStore := storage_provider_store.New() - router := routing.NewService(spStore, logger) - agentStore := agent_store.New() - blobReg, _ := newBlobRegistry() - nodeProvider := piriclient.NewProvider(uploadService, logger) - - ch := handlers.NewHTTPPutConcludeHandler(router, nodeProvider, agentStore, blobReg, logger) - - // Create an invocation with a wrong capability (not http/put) - cap := ucan.NewCapability( - "blob/allocate", - uploadService.DID().String(), - ucan.NoCaveats{}, - ) - putInv, err := invocation.Invoke(uploadService, uploadService, cap) - require.NoError(t, err) - - putRcpt, err := receipt.Issue( - uploadService, - result.Ok[ok.Unit, ipld.Builder](ok.Unit{}), - ran.FromInvocation(putInv), - ) - require.NoError(t, err) - - err = ch.Handler(ctx, putInv, putRcpt, nil) - require.Error(t, err) - require.Contains(t, err.Error(), "matching http/put invocation") - }) - t.Run("allocation invocation not found", func(t *testing.T) { - spStore := storage_provider_store.New() - router := routing.NewService(spStore, logger) - agentStore := agent_store.New() - blobReg, _ := newBlobRegistry() - nodeProvider := piriclient.NewProvider(uploadService, logger) - - ch := handlers.NewHTTPPutConcludeHandler(router, nodeProvider, agentStore, blobReg, logger) + deps := newHTTPPutDeps(t, piriclient.NewProvider(uploadService, logger), logger) digest := testutil.RandomMultihash(t) - // URL.UcanAwait.Link points to a non-existent allocation invocation - nonExistentAllocLink := cidlink.Link{Cid: testutil.RandomCID(t)} - - putInv, err := httpcap.Put.Invoke( - uploadService, uploadService, - uploadService.DID().String(), - httpcap.PutCaveats{ - URL: types.Promise{ - UcanAwait: types.Await{ - Selector: ".out.ok.address.url", - Link: nonExistentAllocLink, - }, - }, - Headers: types.Promise{ - UcanAwait: types.Await{ - Selector: ".out.ok.address.headers", - Link: nonExistentAllocLink, - }, - }, - Body: httpcap.Body{ - Digest: digest, - Size: 1024, - }, + // Destination.Task points to an invocation that's not in the agent store. + nonExistentAllocTask := testutil.RandomCID(t) + + blobProvider := deriveBlobProvider(t, digest) + putInv, err := httpcaps.Put.Invoke( + blobProvider, + blobProvider, + &httpcaps.PutArguments{ + Body: blobcaps.Blob{Digest: digest, Size: 1024}, + Destination: promise.AwaitOK{Task: nonExistentAllocTask}, }, + invocation.WithAudience(blobProvider), ) require.NoError(t, err) putRcpt, err := receipt.Issue( - uploadService, - result.Ok[ok.Unit, ipld.Builder](ok.Unit{}), - ran.FromInvocation(putInv), + blobProvider, + putInv.Task().Link(), + result.OK[ipld.Map, ipld.Any](mustRebindMap(t, &httpcaps.PutOK{})), ) require.NoError(t, err) - err = ch.Handler(ctx, putInv, putRcpt, nil) + err = deps.ch.Handler(ctx, putInv, putRcpt, nil) require.Error(t, err) require.Contains(t, err.Error(), "getting allocation invocation") }) t.Run("storage provider not found", func(t *testing.T) { - storageProvider := testutil.RandomSigner(t) - spStore := storage_provider_store.New() - router := routing.NewService(spStore, logger) - agentStore := agent_store.New() - blobReg, _ := newBlobRegistry() - nodeProvider := piriclient.NewProvider(uploadService, logger) - - ch := handlers.NewHTTPPutConcludeHandler(router, nodeProvider, agentStore, blobReg, logger) + deps := newHTTPPutDeps(t, piriclient.NewProvider(uploadService, logger), logger) + storageProvider := testutil.RandomSigner(t) space := testutil.RandomSigner(t) digest := testutil.RandomMultihash(t) - blob := types.Blob{Digest: digest, Size: 1024} + blob := blobcaps.Blob{Digest: digest, Size: 1024} - // Create and store a blob/allocate invocation - allocInv, err := blobcap.Allocate.Invoke( - uploadService, storageProvider, - storageProvider.DID().String(), - blobcap.AllocateCaveats{ - Space: space.DID(), - Blob: blob, - Cause: cidlink.Link{Cid: testutil.RandomCID(t)}, - }, + // Persist a /blob/allocate invocation for the storage provider, but do + // NOT register that provider in the spStore — router lookup fails. + allocInv, err := blobcaps.Allocate.Invoke( + uploadService, + space, + &blobcaps.AllocateArguments{Blob: blob, Cause: testutil.RandomCID(t)}, + invocation.WithAudience(storageProvider), ) require.NoError(t, err) - allocRcpt, err := receipt.Issue( storageProvider, - result.Ok[ok.Unit, ipld.Builder](ok.Unit{}), - ran.FromInvocation(allocInv), + allocInv.Task().Link(), + result.OK[ipld.Map, ipld.Any](mustRebindMap(t, &blobcaps.AllocateOK{Size: blob.Size})), ) require.NoError(t, err) - - writeAgentMessage(t, agentStore, []invocation.Invocation{allocInv}, []receipt.AnyReceipt{allocRcpt}) - - // Create http/put invocation referencing the allocation - putInv, err := httpcap.Put.Invoke( - uploadService, uploadService, - uploadService.DID().String(), - httpcap.PutCaveats{ - URL: types.Promise{ - UcanAwait: types.Await{ - Selector: ".out.ok.address.url", - Link: allocInv.Link(), - }, - }, - Headers: types.Promise{ - UcanAwait: types.Await{ - Selector: ".out.ok.address.headers", - Link: allocInv.Link(), - }, - }, - Body: httpcap.Body{ - Digest: digest, - Size: 1024, - }, + msg := container.New( + container.WithInvocations(allocInv), + container.WithReceipts(allocRcpt), + ) + require.NoError(t, deps.agentStore.Write(ctx, msg, agent.Index(msg))) + + blobProvider := deriveBlobProvider(t, digest) + putInv, err := httpcaps.Put.Invoke( + blobProvider, + blobProvider, + &httpcaps.PutArguments{ + Body: blob, + Destination: promise.AwaitOK{Task: allocInv.Task().Link()}, }, + invocation.WithAudience(blobProvider), ) require.NoError(t, err) - putRcpt, err := receipt.Issue( - uploadService, - result.Ok[ok.Unit, ipld.Builder](ok.Unit{}), - ran.FromInvocation(putInv), + blobProvider, + putInv.Task().Link(), + result.OK[ipld.Map, ipld.Any](mustRebindMap(t, &httpcaps.PutOK{})), ) require.NoError(t, err) - // storageProvider is NOT registered in spStore, so GetProviderInfo fails - err = ch.Handler(ctx, putInv, putRcpt, nil) + err = deps.ch.Handler(ctx, putInv, putRcpt, nil) require.Error(t, err) require.Contains(t, err.Error(), "getting storage provider info") }) t.Run("success registers blob in space", func(t *testing.T) { storageProvider := testutil.RandomSigner(t) - storageProviderURL := testutil.Must(url.Parse("https://piri.example.com"))(t) - storageProviderProof := delegateStorageProviderProof(t, storageProvider, uploadService) - - spStore := storage_provider_store.New() - err := spStore.Put(ctx, *storageProviderURL, storageProviderProof, 100, nil) - require.NoError(t, err) - - router := routing.NewService(spStore, logger) - agentStore := agent_store.New() - blobReg, consumerStore := newBlobRegistry() - space := testutil.RandomSigner(t) digest := testutil.RandomMultihash(t) - blob := types.Blob{Digest: digest, Size: 1024} + blob := blobcaps.Blob{Digest: digest, Size: 1024} + blobAddTaskLink := testutil.RandomCID(t) + + // Stand up a mock piri server. The handler under test only calls + // /blob/accept; the allocate handler is irrelevant but the helper + // requires both. + acceptOK := &blobcaps.AcceptOK{Site: testutil.RandomCID(t)} + piriSrv := newMockPiriServer( + t, storageProvider, uploadService, + &blobcaps.AllocateOK{Size: blob.Size}, + acceptOK, + ) + piriURL := testutil.Must(url.Parse(piriSrv.URL))(t) - // provision the space so blob registry Register succeeds - aliceAccount := testutil.Must(didmailto.Parse("did:mailto:example.com:alice"))(t) - err = consumerStore.Add(ctx, uploadService.DID(), space.DID(), aliceAccount, "", testutil.RandomCID(t)) - require.NoError(t, err) + deps := newHTTPPutDeps(t, piriclient.NewProvider(uploadService, logger), logger) + require.NoError(t, deps.spStore.Put(ctx, storageProvider.DID(), *piriURL, 100, nil)) - // Create and store a blob/allocate invocation - allocInv, err := blobcap.Allocate.Invoke( - uploadService, storageProvider, - storageProvider.DID().String(), - blobcap.AllocateCaveats{ - Space: space.DID(), - Blob: blob, - Cause: cidlink.Link{Cid: testutil.RandomCID(t)}, - }, + // Provision the space so blob_registry.Register succeeds. + account := testutil.Must(didmailto.New("alice@example.com"))(t) + require.NoError(t, deps.consumerStore.Add( + ctx, uploadService.DID(), space.DID(), account, "sub-1", testutil.RandomCID(t), + )) + + // Prior /blob/allocate invocation in the agent store. + allocInv, err := blobcaps.Allocate.Invoke( + uploadService, + space, + &blobcaps.AllocateArguments{Blob: blob, Cause: blobAddTaskLink}, + invocation.WithAudience(storageProvider), ) require.NoError(t, err) - allocRcpt, err := receipt.Issue( storageProvider, - result.Ok[ok.Unit, ipld.Builder](ok.Unit{}), - ran.FromInvocation(allocInv), + allocInv.Task().Link(), + result.OK[ipld.Map, ipld.Any](mustRebindMap(t, &blobcaps.AllocateOK{Size: blob.Size})), ) require.NoError(t, err) - - writeAgentMessage(t, agentStore, []invocation.Invocation{allocInv}, []receipt.AnyReceipt{allocRcpt}) - - // Create http/put invocation referencing the allocation - putInv, err := httpcap.Put.Invoke( - uploadService, uploadService, - uploadService.DID().String(), - httpcap.PutCaveats{ - URL: types.Promise{ - UcanAwait: types.Await{ - Selector: ".out.ok.address.url", - Link: allocInv.Link(), - }, - }, - Headers: types.Promise{ - UcanAwait: types.Await{ - Selector: ".out.ok.address.headers", - Link: allocInv.Link(), - }, - }, - Body: httpcap.Body{ - Digest: digest, - Size: 1024, - }, + msg := container.New( + container.WithInvocations(allocInv), + container.WithReceipts(allocRcpt), + ) + require.NoError(t, deps.agentStore.Write(ctx, msg, agent.Index(msg))) + + // /http/put invocation referring to the allocation task. + blobProvider := deriveBlobProvider(t, digest) + putInv, err := httpcaps.Put.Invoke( + blobProvider, + blobProvider, + &httpcaps.PutArguments{ + Body: blob, + Destination: promise.AwaitOK{Task: allocInv.Task().Link()}, }, + invocation.WithAudience(blobProvider), ) require.NoError(t, err) - putRcpt, err := receipt.Issue( - uploadService, - result.Ok[ok.Unit, ipld.Builder](ok.Unit{}), - ran.FromInvocation(putInv), + blobProvider, + putInv.Task().Link(), + result.OK[ipld.Map, ipld.Any](mustRebindMap(t, &httpcaps.PutOK{})), ) require.NoError(t, err) - // Create mock node provider with accept handler that returns success - acceptOk := blobcap.AcceptOk{ - Site: cidlink.Link{Cid: testutil.RandomCID(t)}, - } - mockProvider := newMockNodeProvider( - t, - uploadService, - storageProvider, - newOkHandler[blobcap.AllocateCaveats](t, blobcap.AllocateOk{}), - newOkHandler[blobcap.AcceptCaveats](t, acceptOk), - logger, - ) + // Authorize the upload service to invoke /blob/accept on the space and + // pass the proof through the conclude metadata so the piri client can + // forward it to the storage provider. + acceptProof, err := delegation.Delegate(space, uploadService, space, blobcaps.AcceptCommand) + require.NoError(t, err) + meta := container.New(container.WithDelegations(acceptProof)) - ch := handlers.NewHTTPPutConcludeHandler(router, mockProvider, agentStore, blobReg, logger) + err = deps.ch.Handler(ctx, putInv, putRcpt, meta) + require.NoError(t, err) - err = ch.Handler(ctx, putInv, putRcpt, nil) + // Blob should now be registered in the space, with cause = blobAddTaskLink. + rec, err := deps.blobReg.Get(ctx, space.DID(), digest) require.NoError(t, err) + require.Equal(t, blobAddTaskLink, rec.Cause) + require.Equal(t, blob.Size, rec.Blob.Size) }) } diff --git a/pkg/service/handlers/ucan_conclude_test.go b/pkg/service/handlers/ucan_conclude_test.go index 11cdd71..e2e7420 100644 --- a/pkg/service/handlers/ucan_conclude_test.go +++ b/pkg/service/handlers/ucan_conclude_test.go @@ -4,205 +4,241 @@ import ( "context" "testing" - ucancap "github.com/storacha/go-libstoracha/capabilities/ucan" - "github.com/storacha/go-ucanto/core/invocation" - "github.com/storacha/go-ucanto/core/ipld" - "github.com/storacha/go-ucanto/core/receipt" - "github.com/storacha/go-ucanto/core/receipt/ran" - "github.com/storacha/go-ucanto/core/result" - "github.com/storacha/go-ucanto/core/result/ok" - "github.com/storacha/go-ucanto/server" - "github.com/storacha/go-ucanto/ucan" + ucancaps "github.com/fil-forge/libforge/capabilities/ucan" + edm "github.com/fil-forge/ucantone/errors/datamodel" + "github.com/fil-forge/ucantone/execution" + "github.com/fil-forge/ucantone/ipld" + "github.com/fil-forge/ucantone/ipld/datamodel" + "github.com/fil-forge/ucantone/result" + "github.com/fil-forge/ucantone/ucan" + "github.com/fil-forge/ucantone/ucan/container" + "github.com/fil-forge/ucantone/ucan/invocation" + "github.com/fil-forge/ucantone/ucan/receipt" "github.com/storacha/sprue/internal/testutil" "github.com/storacha/sprue/pkg/identity" "github.com/storacha/sprue/pkg/service/handlers" + "github.com/storacha/sprue/pkg/store/agent" agent_store "github.com/storacha/sprue/pkg/store/agent/memory" "github.com/stretchr/testify/require" "go.uber.org/zap/zaptest" ) -// issueConclude creates a ucan/conclude invocation with the receipt blocks attached. -func issueConclude(t *testing.T, signer ucan.Signer, rcpt receipt.AnyReceipt) invocation.Invocation { - t.Helper() - inv, err := ucancap.Conclude.Invoke( - signer, signer, - signer.DID().String(), - ucancap.ConcludeCaveats{ - Receipt: rcpt.Root().Link(), - }, - ) - require.NoError(t, err) - - for blk, err := range rcpt.Blocks() { - require.NoError(t, err) - require.NoError(t, inv.Attach(blk)) - } - return inv -} - func TestUCANConcludeHandler(t *testing.T) { logger := zaptest.NewLogger(t) ctx := t.Context() uploadService := testutil.WebService - t.Run("receipt not readable from blocks", func(t *testing.T) { + // Build a "task" invocation and a receipt for it. + newTaskAndReceipt := func(t *testing.T, cmd ucan.Command) (ucan.Invocation, ucan.Receipt) { + t.Helper() + taskInv, err := invocation.Invoke(uploadService, uploadService, cmd, datamodel.Map{}) + require.NoError(t, err) + rcpt, err := receipt.Issue( + uploadService, + taskInv.Task().Link(), + result.OK[int64, ipld.Any](int64(1)), + ) + require.NoError(t, err) + return taskInv, rcpt + } + + t.Run("receipt not in metadata", func(t *testing.T) { agentStore := agent_store.New() - handlerMap := map[ucan.Ability]handlers.ConclusionHandlerFunc{} + handlerMap := map[ucan.Command]handlers.ConclusionHandlerFunc{} - handler := handlers.UCANConcludeHandler( + handler := handlers.NewUCANConcludeHandler( &identity.Identity{Signer: uploadService}, agentStore, handlerMap, logger, ) - // Create a conclude invocation WITHOUT attaching the receipt blocks - taskInv, err := invocation.Invoke( - uploadService, uploadService, - ucan.NewCapability("test/thing", uploadService.DID().String(), ucan.NoCaveats{}), - ) - require.NoError(t, err) + _, rcpt := newTaskAndReceipt(t, "/test/thing") - rcpt, err := receipt.Issue( + concludeInv, err := ucancaps.Conclude.Invoke( uploadService, - result.Ok[ok.Unit, ipld.Builder](ok.Unit{}), - ran.FromInvocation(taskInv), + uploadService, + &ucancaps.ConcludeArguments{Receipt: rcpt.Link()}, + invocation.WithAudience(uploadService), ) require.NoError(t, err) - // Invoke conclude but don't attach receipt blocks - concludeInv, err := ucancap.Conclude.Invoke( - uploadService, uploadService, - uploadService.DID().String(), - ucancap.ConcludeCaveats{ - Receipt: rcpt.Root().Link(), - }, - ) + // The receipt is referenced in args but NOT attached to the request + // metadata, so the handler can't find it. + req := execution.NewRequest(ctx, concludeInv) + res, err := execution.NewResponse(req.Invocation().Task().Link(), execution.WithSigner(uploadService)) require.NoError(t, err) - concludeCap := ucancap.Conclude.New( - uploadService.DID().String(), - ucancap.ConcludeCaveats{Receipt: rcpt.Root().Link()}, - ) - _, _, err = handler(ctx, concludeCap, concludeInv, nil) - require.Error(t, err) - require.Contains(t, err.Error(), "reading receipt") + err = handler.Handler(req, res) + require.NoError(t, err) + + _, fail := result.Unwrap(res.Receipt().Out()) + require.NotNil(t, fail) + + model := edm.ErrorModel{} + err = datamodel.Rebind(datamodel.NewAny(fail), &model) + require.NoError(t, err) + require.Equal(t, handlers.ConclusionReceiptNotFoundErrorName, model.Name()) }) t.Run("unknown invocation returns success", func(t *testing.T) { agentStore := agent_store.New() - handlerMap := map[ucan.Ability]handlers.ConclusionHandlerFunc{} + handlerMap := map[ucan.Command]handlers.ConclusionHandlerFunc{} - handler := handlers.UCANConcludeHandler( + handler := handlers.NewUCANConcludeHandler( &identity.Identity{Signer: uploadService}, agentStore, handlerMap, logger, ) - // Create a receipt for some task that is NOT in the agent store - // and is NOT embedded in the receipt as an invocation - taskInv, err := invocation.Invoke( - uploadService, uploadService, - ucan.NewCapability("test/thing", uploadService.DID().String(), ucan.NoCaveats{}), - ) - require.NoError(t, err) + // Receipt is supplied but the ran invocation is neither in the request + // metadata nor in the agent store — the handler treats this as a no-op. + _, rcpt := newTaskAndReceipt(t, "/test/thing") - rcpt, err := receipt.Issue( + concludeInv, err := ucancaps.Conclude.Invoke( uploadService, - result.Ok[ok.Unit, ipld.Builder](ok.Unit{}), - ran.FromInvocation(taskInv), + uploadService, + &ucancaps.ConcludeArguments{Receipt: rcpt.Link()}, + invocation.WithAudience(uploadService), ) require.NoError(t, err) - concludeInv := issueConclude(t, uploadService, rcpt) - concludeCap := ucancap.Conclude.New( - uploadService.DID().String(), - ucancap.ConcludeCaveats{Receipt: rcpt.Root().Link()}, - ) - res, _, err := handler(ctx, concludeCap, concludeInv, nil) + req := execution.NewRequest(ctx, concludeInv, execution.WithReceipts(rcpt)) + res, err := execution.NewResponse(req.Invocation().Task().Link(), execution.WithSigner(uploadService)) require.NoError(t, err) - o, x := result.Unwrap(res) - require.Nil(t, x) - require.NotNil(t, o) + err = handler.Handler(req, res) + require.NoError(t, err) + + _, fail := result.Unwrap(res.Receipt().Out()) + require.Nil(t, fail) }) t.Run("dispatches to registered handler", func(t *testing.T) { agentStore := agent_store.New() - var called bool - handlerMap := map[ucan.Ability]handlers.ConclusionHandlerFunc{ - "test/thing": func(_ context.Context, _ invocation.Invocation, _ receipt.AnyReceipt, _ server.InvocationContext) error { + var ( + called bool + gotInv ucan.Invocation + gotRcpt ucan.Receipt + ) + handlerMap := map[ucan.Command]handlers.ConclusionHandlerFunc{ + "/test/thing": func(_ context.Context, inv ucan.Invocation, rcpt ucan.Receipt, _ ucan.Container) error { called = true + gotInv = inv + gotRcpt = rcpt return nil }, } - handler := handlers.UCANConcludeHandler( + handler := handlers.NewUCANConcludeHandler( &identity.Identity{Signer: uploadService}, agentStore, handlerMap, logger, ) - // Create a task invocation with "test/thing" ability - taskInv, err := invocation.Invoke( - uploadService, uploadService, - ucan.NewCapability("test/thing", uploadService.DID().String(), ucan.NoCaveats{}), + taskInv, rcpt := newTaskAndReceipt(t, "/test/thing") + + // Persist the task invocation in the agent store so the handler can + // look it up by the receipt's ran CID. + msg := container.New( + container.WithInvocations(taskInv), + container.WithReceipts(rcpt), ) - require.NoError(t, err) + require.NoError(t, agentStore.Write(ctx, msg, agent.Index(msg))) - rcpt, err := receipt.Issue( + concludeInv, err := ucancaps.Conclude.Invoke( + uploadService, uploadService, - result.Ok[ok.Unit, ipld.Builder](ok.Unit{}), - ran.FromInvocation(taskInv), + &ucancaps.ConcludeArguments{Receipt: rcpt.Link()}, + invocation.WithAudience(uploadService), ) require.NoError(t, err) - // Store the task invocation so the handler can find it - writeAgentMessage(t, agentStore, []invocation.Invocation{taskInv}, []receipt.AnyReceipt{rcpt}) + req := execution.NewRequest(ctx, concludeInv, execution.WithReceipts(rcpt)) + res, err := execution.NewResponse(req.Invocation().Task().Link(), execution.WithSigner(uploadService)) + require.NoError(t, err) - concludeInv := issueConclude(t, uploadService, rcpt) - concludeCap := ucancap.Conclude.New( - uploadService.DID().String(), - ucancap.ConcludeCaveats{Receipt: rcpt.Root().Link()}, - ) - res, _, err := handler(ctx, concludeCap, concludeInv, nil) + err = handler.Handler(req, res) require.NoError(t, err) - require.True(t, called) - o, x := result.Unwrap(res) - require.Nil(t, x) - require.NotNil(t, o) + _, fail := result.Unwrap(res.Receipt().Out()) + require.Nil(t, fail) + + require.True(t, called) + require.Equal(t, taskInv.Task().Link(), gotInv.Task().Link()) + require.Equal(t, rcpt.Link(), gotRcpt.Link()) }) - t.Run("no handler for ability returns success", func(t *testing.T) { + t.Run("no handler for command returns success", func(t *testing.T) { agentStore := agent_store.New() - // Register no handlers - handlerMap := map[ucan.Ability]handlers.ConclusionHandlerFunc{} + // No handlers registered. + handlerMap := map[ucan.Command]handlers.ConclusionHandlerFunc{} - handler := handlers.UCANConcludeHandler( + handler := handlers.NewUCANConcludeHandler( &identity.Identity{Signer: uploadService}, agentStore, handlerMap, logger, ) - taskInv, err := invocation.Invoke( - uploadService, uploadService, - ucan.NewCapability("test/unhandled", uploadService.DID().String(), ucan.NoCaveats{}), + taskInv, rcpt := newTaskAndReceipt(t, "/test/unhandled") + + msg := container.New( + container.WithInvocations(taskInv), + container.WithReceipts(rcpt), ) - require.NoError(t, err) + require.NoError(t, agentStore.Write(ctx, msg, agent.Index(msg))) - rcpt, err := receipt.Issue( + concludeInv, err := ucancaps.Conclude.Invoke( + uploadService, uploadService, - result.Ok[ok.Unit, ipld.Builder](ok.Unit{}), - ran.FromInvocation(taskInv), + &ucancaps.ConcludeArguments{Receipt: rcpt.Link()}, + invocation.WithAudience(uploadService), ) require.NoError(t, err) - writeAgentMessage(t, agentStore, []invocation.Invocation{taskInv}, []receipt.AnyReceipt{rcpt}) + req := execution.NewRequest(ctx, concludeInv, execution.WithReceipts(rcpt)) + res, err := execution.NewResponse(req.Invocation().Task().Link(), execution.WithSigner(uploadService)) + require.NoError(t, err) + + err = handler.Handler(req, res) + require.NoError(t, err) + + _, fail := result.Unwrap(res.Receipt().Out()) + require.Nil(t, fail) + }) + + t.Run("invocation supplied via metadata", func(t *testing.T) { + agentStore := agent_store.New() + + var called bool + handlerMap := map[ucan.Command]handlers.ConclusionHandlerFunc{ + "/test/thing": func(_ context.Context, _ ucan.Invocation, _ ucan.Receipt, _ ucan.Container) error { + called = true + return nil + }, + } - concludeInv := issueConclude(t, uploadService, rcpt) - concludeCap := ucancap.Conclude.New( - uploadService.DID().String(), - ucancap.ConcludeCaveats{Receipt: rcpt.Root().Link()}, + handler := handlers.NewUCANConcludeHandler( + &identity.Identity{Signer: uploadService}, agentStore, handlerMap, logger, ) - res, _, err := handler(ctx, concludeCap, concludeInv, nil) + + taskInv, rcpt := newTaskAndReceipt(t, "/test/thing") + + // The ran invocation is supplied directly in the request metadata — + // no agent-store lookup required. + concludeInv, err := ucancaps.Conclude.Invoke( + uploadService, + uploadService, + &ucancaps.ConcludeArguments{Receipt: rcpt.Link()}, + invocation.WithAudience(uploadService), + ) + require.NoError(t, err) + + req := execution.NewRequest(ctx, concludeInv, + execution.WithReceipts(rcpt), + execution.WithInvocations(taskInv), + ) + res, err := execution.NewResponse(req.Invocation().Task().Link(), execution.WithSigner(uploadService)) + require.NoError(t, err) + + err = handler.Handler(req, res) require.NoError(t, err) - o, x := result.Unwrap(res) - require.Nil(t, x) - require.NotNil(t, o) + _, fail := result.Unwrap(res.Receipt().Out()) + require.Nil(t, fail) + require.True(t, called) }) } diff --git a/pkg/service/handlers/upload_add.go b/pkg/service/handlers/upload_add.go index af82f1e..7b3a655 100644 --- a/pkg/service/handlers/upload_add.go +++ b/pkg/service/handlers/upload_add.go @@ -1,84 +1,54 @@ package handlers import ( - "context" "fmt" - "github.com/ipfs/go-cid" - "github.com/storacha/go-libstoracha/capabilities/upload" - "github.com/storacha/go-ucanto/core/invocation" - "github.com/storacha/go-ucanto/core/receipt/fx" - "github.com/storacha/go-ucanto/core/result" - "github.com/storacha/go-ucanto/core/result/failure" - "github.com/storacha/go-ucanto/did" - "github.com/storacha/go-ucanto/server" - "github.com/storacha/go-ucanto/ucan" - "github.com/storacha/sprue/pkg/internal/ipldutil" - "github.com/storacha/sprue/pkg/lib/errors" + uploadcaps "github.com/fil-forge/libforge/capabilities/upload" + "github.com/fil-forge/ucantone/errors" + "github.com/fil-forge/ucantone/execution/bindexec" + "github.com/storacha/sprue/pkg/provisioning" upload_store "github.com/storacha/sprue/pkg/store/upload" "go.uber.org/zap" ) -const InvalidSpaceErrorName = "InvalidSpace" - -// WithUploadAddMethod registers the upload/add handler. // This handler registers an upload (root CID + shards mapping). -func WithUploadAddMethod(uploadStore upload_store.Store, logger *zap.Logger) server.Option { - return server.WithServiceMethod( - upload.AddAbility, - server.Provide(upload.Add, UploadAddHandler(uploadStore, logger)), - ) -} - -func UploadAddHandler(uploadStore upload_store.Store, logger *zap.Logger) server.HandlerFunc[upload.AddCaveats, upload.AddOk, failure.IPLDBuilderFailure] { - log := logger.With(zap.String("handler", upload.AddAbility)) - return server.HandlerFunc[upload.AddCaveats, upload.AddOk, failure.IPLDBuilderFailure]( - func(ctx context.Context, - cap ucan.Capability[upload.AddCaveats], - inv invocation.Invocation, - iCtx server.InvocationContext, - ) (result.Result[upload.AddOk, failure.IPLDBuilderFailure], fx.Effects, error) { +func NewUploadAddHandler(provisioningSvc *provisioning.Service, uploadStore upload_store.Store, logger *zap.Logger) Handler { + log := logger.With(zap.String("handler", uploadcaps.AddCommand)) + return Handler{ + Capability: uploadcaps.Add, + Handler: bindexec.NewHandler(func( + req *bindexec.Request[*uploadcaps.AddArguments], + res *bindexec.Response[*uploadcaps.AddOK], + ) error { + args := req.Task().BindArguments() + space := req.Invocation().Subject() + cause := req.Invocation().Task().Link() log := log.With( - zap.String("space", cap.With()), - zap.Stringer("root", cap.Nb().Root), - zap.Int("shards", len(cap.Nb().Shards)), + zap.Stringer("space", space.DID()), + zap.Stringer("root", args.Root), ) - log.Debug("adding upload") - - space, err := did.Parse(cap.With()) - if err != nil { - return result.Error[upload.AddOk, failure.IPLDBuilderFailure]( - errors.New(InvalidSpaceErrorName, "invalid space DID: %v", err), - ), nil, nil + if args.Index != nil { + log = log.With(zap.Stringer("index", *args.Index)) } + log.Debug("adding upload") - root, err := ipldutil.ToCID(cap.Nb().Root) + provs, err := provisioningSvc.ListServiceProviders(req.Context(), space.DID()) if err != nil { - return nil, nil, err - } - - shards := make([]cid.Cid, 0, len(cap.Nb().Shards)) - for _, link := range cap.Nb().Shards { - s, err := ipldutil.ToCID(link) - if err != nil { - return nil, nil, err - } - shards = append(shards, s) + log.Error("failed to list service providers", zap.Error(err)) + return fmt.Errorf("listing service providers: %w", err) } - - cause, err := ipldutil.ToCID(inv.Link()) - if err != nil { - return nil, nil, err + if len(provs) == 0 { + log.Warn("space has no service provider") + return res.SetFailure(errors.New(InsufficientStorageErrorName, "space has no service provider")) } - err = uploadStore.Upsert(ctx, space, root, shards, cause) + err = uploadStore.Upsert(req.Context(), space.DID(), args.Root, args.Index, args.Shards, cause) if err != nil { log.Error("failed to upsert upload", zap.Error(err)) - return nil, nil, fmt.Errorf("upserting upload: %w", err) + return fmt.Errorf("upserting upload: %w", err) } - return result.Ok[upload.AddOk, failure.IPLDBuilderFailure](upload.AddOk{ - Root: cap.Nb().Root, - }), nil, nil - }) + return res.SetSuccess(&uploadcaps.AddOK{}) + }), + } } diff --git a/pkg/service/handlers/upload_add_test.go b/pkg/service/handlers/upload_add_test.go index 47c837f..41dd6c7 100644 --- a/pkg/service/handlers/upload_add_test.go +++ b/pkg/service/handlers/upload_add_test.go @@ -1,21 +1,88 @@ package handlers_test import ( + "context" "testing" - cidlink "github.com/ipld/go-ipld-prime/linking/cid" - "github.com/storacha/go-libstoracha/capabilities/upload" - "github.com/storacha/go-ucanto/core/invocation" - "github.com/storacha/go-ucanto/core/result" - "github.com/storacha/go-ucanto/core/result/failure/datamodel" - "github.com/storacha/go-ucanto/ucan" + uploadcaps "github.com/fil-forge/libforge/capabilities/upload" + "github.com/fil-forge/libforge/didmailto" + "github.com/fil-forge/ucantone/did" + edm "github.com/fil-forge/ucantone/errors/datamodel" + "github.com/fil-forge/ucantone/execution" + "github.com/fil-forge/ucantone/ipld/datamodel" + "github.com/fil-forge/ucantone/principal" + "github.com/fil-forge/ucantone/result" + "github.com/fil-forge/ucantone/ucan/invocation" + "github.com/ipfs/go-cid" "github.com/storacha/sprue/internal/testutil" + "github.com/storacha/sprue/pkg/provisioning" "github.com/storacha/sprue/pkg/service/handlers" - uploadmemory "github.com/storacha/sprue/pkg/store/upload/memory" + consumer_store "github.com/storacha/sprue/pkg/store/consumer/memory" + subscription_store "github.com/storacha/sprue/pkg/store/subscription/memory" + upload_store "github.com/storacha/sprue/pkg/store/upload/memory" "github.com/stretchr/testify/require" + "go.uber.org/zap" "go.uber.org/zap/zaptest" ) +type uploadAddDeps struct { + handler handlers.Handler + store *upload_store.Store + consumerStore *consumer_store.Store +} + +func newUploadAddDeps(t *testing.T, uploadService principal.Signer, logger *zap.Logger) *uploadAddDeps { + t.Helper() + consumerStore := consumer_store.New() + provisioningSvc := provisioning.NewService( + []did.DID{uploadService.DID()}, + consumerStore, + subscription_store.New(), + ) + store := upload_store.New() + handler := handlers.NewUploadAddHandler(provisioningSvc, store, logger) + return &uploadAddDeps{handler: handler, store: store, consumerStore: consumerStore} +} + +// invokeUploadAdd builds an /upload/add invocation with the given args and a +// signed response ready for the handler. +func invokeUploadAdd( + t *testing.T, + ctx context.Context, + agent principal.Signer, + uploadService principal.Signer, + space principal.Signer, + args *uploadcaps.AddArguments, +) (execution.Request, *execution.ExecResponse) { + t.Helper() + inv, err := uploadcaps.Add.Invoke( + agent, + space, + args, + invocation.WithAudience(uploadService), + ) + require.NoError(t, err) + req := execution.NewRequest(ctx, inv) + res, err := execution.NewResponse(req.Invocation().Task().Link(), execution.WithSigner(uploadService)) + require.NoError(t, err) + return req, res +} + +// provisionUploadSpace adds a consumer record so the upload service shows up as +// a provider for the space when the handler calls ListServiceProviders. +func provisionUploadSpace(t *testing.T, consumerStore *consumer_store.Store, uploadService principal.Signer, space principal.Signer) { + t.Helper() + account := testutil.Must(didmailto.New("alice@example.com"))(t) + require.NoError(t, consumerStore.Add( + t.Context(), + uploadService.DID(), + space.DID(), + account, + "sub-1", + testutil.RandomCID(t), + )) +} + func TestUploadAddHandler(t *testing.T) { logger := zaptest.NewLogger(t) ctx := t.Context() @@ -23,143 +90,135 @@ func TestUploadAddHandler(t *testing.T) { uploadService := testutil.WebService alice := testutil.Alice - t.Run("invalid space DID", func(t *testing.T) { - store := uploadmemory.New() - handler := handlers.UploadAddHandler(store, logger) + t.Run("space not provisioned", func(t *testing.T) { + deps := newUploadAddDeps(t, uploadService, logger) - root := cidlink.Link{Cid: testutil.RandomCID(t)} - cap := ucan.NewCapability( - upload.AddAbility, - "not-a-did", - upload.AddCaveats{Root: root}, - ) - - inv, err := invocation.Invoke(alice, uploadService, cap) - require.NoError(t, err) + space := testutil.RandomSigner(t) + root := testutil.RandomCID(t) + req, res := invokeUploadAdd(t, ctx, alice, uploadService, space, &uploadcaps.AddArguments{Root: root}) - res, _, err := handler(ctx, cap, inv, nil) + err := deps.handler.Handler(req, res) require.NoError(t, err) - _, fail := result.Unwrap(res) + _, fail := result.Unwrap(res.Receipt().Out()) require.NotNil(t, fail) - model := datamodel.Bind(testutil.Must(fail.ToIPLD())(t)) - require.NotNil(t, model.Name) - require.Equal(t, handlers.InvalidSpaceErrorName, *model.Name) + model := edm.ErrorModel{} + require.NoError(t, datamodel.Rebind(datamodel.NewAny(fail), &model)) + require.Equal(t, handlers.InsufficientStorageErrorName, model.Name()) + + // Nothing should have been persisted. + exists, err := deps.store.Exists(ctx, space.DID(), root) + require.NoError(t, err) + require.False(t, exists) }) t.Run("success with no shards", func(t *testing.T) { - store := uploadmemory.New() - handler := handlers.UploadAddHandler(store, logger) + deps := newUploadAddDeps(t, uploadService, logger) space := testutil.RandomSigner(t) - root := cidlink.Link{Cid: testutil.RandomCID(t)} - cap := ucan.NewCapability( - upload.AddAbility, - space.DID().String(), - upload.AddCaveats{Root: root}, - ) - - inv, err := invocation.Invoke(alice, uploadService, cap) - require.NoError(t, err) + provisionUploadSpace(t, deps.consumerStore, uploadService, space) - res, _, err := handler(ctx, cap, inv, nil) + root := testutil.RandomCID(t) + req, res := invokeUploadAdd(t, ctx, alice, uploadService, space, &uploadcaps.AddArguments{Root: root}) + + err := deps.handler.Handler(req, res) require.NoError(t, err) - ok, fail := result.Unwrap(res) + _, fail := result.Unwrap(res.Receipt().Out()) require.Nil(t, fail) - require.Equal(t, root.String(), ok.Root.String()) - // Verify persisted - exists, err := store.Exists(ctx, space.DID(), testutil.RandomCID(t)) + // Upload should be persisted. + exists, err := deps.store.Exists(ctx, space.DID(), root) require.NoError(t, err) - require.False(t, exists) + require.True(t, exists) - exists, err = store.Exists(ctx, space.DID(), root.Cid) + // Unrelated CID should not be present. + exists, err = deps.store.Exists(ctx, space.DID(), testutil.RandomCID(t)) require.NoError(t, err) - require.True(t, exists) + require.False(t, exists) }) t.Run("success with shards", func(t *testing.T) { - store := uploadmemory.New() - handler := handlers.UploadAddHandler(store, logger) + deps := newUploadAddDeps(t, uploadService, logger) space := testutil.RandomSigner(t) - root := cidlink.Link{Cid: testutil.RandomCID(t)} - shard1 := cidlink.Link{Cid: testutil.RandomCID(t)} - shard2 := cidlink.Link{Cid: testutil.RandomCID(t)} - - cap := ucan.NewCapability( - upload.AddAbility, - space.DID().String(), - upload.AddCaveats{ - Root: root, - Shards: []ucan.Link{shard1, shard2}, - }, - ) - - inv, err := invocation.Invoke(alice, uploadService, cap) - require.NoError(t, err) + provisionUploadSpace(t, deps.consumerStore, uploadService, space) + + root := testutil.RandomCID(t) + shard1 := testutil.RandomCID(t) + shard2 := testutil.RandomCID(t) + + req, res := invokeUploadAdd(t, ctx, alice, uploadService, space, &uploadcaps.AddArguments{ + Root: root, + Shards: []cid.Cid{shard1, shard2}, + }) - res, _, err := handler(ctx, cap, inv, nil) + err := deps.handler.Handler(req, res) require.NoError(t, err) - ok, fail := result.Unwrap(res) + _, fail := result.Unwrap(res.Receipt().Out()) require.Nil(t, fail) - require.Equal(t, root.String(), ok.Root.String()) - // Verify upload exists - exists, err := store.Exists(ctx, space.DID(), root.Cid) + exists, err := deps.store.Exists(ctx, space.DID(), root) require.NoError(t, err) require.True(t, exists) }) - t.Run("upsert updates existing upload", func(t *testing.T) { - store := uploadmemory.New() - handler := handlers.UploadAddHandler(store, logger) + t.Run("success with index", func(t *testing.T) { + deps := newUploadAddDeps(t, uploadService, logger) space := testutil.RandomSigner(t) - root := cidlink.Link{Cid: testutil.RandomCID(t)} - shard1 := cidlink.Link{Cid: testutil.RandomCID(t)} - - cap1 := ucan.NewCapability( - upload.AddAbility, - space.DID().String(), - upload.AddCaveats{ - Root: root, - Shards: []ucan.Link{shard1}, - }, - ) - - inv1, err := invocation.Invoke(alice, uploadService, cap1) - require.NoError(t, err) + provisionUploadSpace(t, deps.consumerStore, uploadService, space) - res1, _, err := handler(ctx, cap1, inv1, nil) - require.NoError(t, err) - _, fail1 := result.Unwrap(res1) - require.Nil(t, fail1) + root := testutil.RandomCID(t) + index := testutil.RandomCID(t) + + req, res := invokeUploadAdd(t, ctx, alice, uploadService, space, &uploadcaps.AddArguments{ + Root: root, + Index: &index, + }) - // Add again with a new shard - shard2 := cidlink.Link{Cid: testutil.RandomCID(t)} - cap2 := ucan.NewCapability( - upload.AddAbility, - space.DID().String(), - upload.AddCaveats{ - Root: root, - Shards: []ucan.Link{shard2}, - }, - ) - - inv2, err := invocation.Invoke(alice, uploadService, cap2) + err := deps.handler.Handler(req, res) require.NoError(t, err) - res2, _, err := handler(ctx, cap2, inv2, nil) + _, fail := result.Unwrap(res.Receipt().Out()) + require.Nil(t, fail) + + exists, err := deps.store.Exists(ctx, space.DID(), root) require.NoError(t, err) - _, fail2 := result.Unwrap(res2) + require.True(t, exists) + }) + + t.Run("upsert updates existing upload", func(t *testing.T) { + deps := newUploadAddDeps(t, uploadService, logger) + + space := testutil.RandomSigner(t) + provisionUploadSpace(t, deps.consumerStore, uploadService, space) + + root := testutil.RandomCID(t) + shard1 := testutil.RandomCID(t) + + req1, res1 := invokeUploadAdd(t, ctx, alice, uploadService, space, &uploadcaps.AddArguments{ + Root: root, + Shards: []cid.Cid{shard1}, + }) + require.NoError(t, deps.handler.Handler(req1, res1)) + _, fail1 := result.Unwrap(res1.Receipt().Out()) + require.Nil(t, fail1) + + // Add again with a new shard. + shard2 := testutil.RandomCID(t) + req2, res2 := invokeUploadAdd(t, ctx, alice, uploadService, space, &uploadcaps.AddArguments{ + Root: root, + Shards: []cid.Cid{shard2}, + }) + require.NoError(t, deps.handler.Handler(req2, res2)) + _, fail2 := result.Unwrap(res2.Receipt().Out()) require.Nil(t, fail2) - // Upload should still exist - exists, err := store.Exists(ctx, space.DID(), root.Cid) + // Upload should still exist. + exists, err := deps.store.Exists(ctx, space.DID(), root) require.NoError(t, err) require.True(t, exists) }) diff --git a/pkg/service/handlers/upload_list.go b/pkg/service/handlers/upload_list.go index 43bfcdf..802002a 100644 --- a/pkg/service/handlers/upload_list.go +++ b/pkg/service/handlers/upload_list.go @@ -1,46 +1,29 @@ package handlers import ( - "context" "fmt" - cidlink "github.com/ipld/go-ipld-prime/linking/cid" - "github.com/storacha/go-libstoracha/capabilities/upload" - "github.com/storacha/go-ucanto/core/invocation" - "github.com/storacha/go-ucanto/core/receipt/fx" - "github.com/storacha/go-ucanto/core/result" - "github.com/storacha/go-ucanto/core/result/failure" - "github.com/storacha/go-ucanto/did" - "github.com/storacha/go-ucanto/server" - "github.com/storacha/go-ucanto/ucan" - "github.com/storacha/sprue/pkg/lib/errors" + uploadcaps "github.com/fil-forge/libforge/capabilities/upload" + "github.com/fil-forge/ucantone/execution/bindexec" upload_store "github.com/storacha/sprue/pkg/store/upload" "go.uber.org/zap" ) -// WithUploadAddMethod registers the upload/add handler. -// This handler registers an upload (root CID + shards mapping). -func WithUploadListMethod(uploadStore upload_store.Store, logger *zap.Logger) server.Option { - return server.WithServiceMethod( - upload.AddAbility, - server.Provide(upload.Add, UploadAddHandler(uploadStore, logger)), - ) -} - -func UploadListHandler(uploadStore upload_store.Store, logger *zap.Logger) server.HandlerFunc[upload.ListCaveats, upload.ListOk, failure.IPLDBuilderFailure] { - log := logger.With(zap.String("handler", upload.ListAbility)) - return server.HandlerFunc[upload.ListCaveats, upload.ListOk, failure.IPLDBuilderFailure]( - func(ctx context.Context, - cap ucan.Capability[upload.ListCaveats], - inv invocation.Invocation, - iCtx server.InvocationContext, - ) (result.Result[upload.ListOk, failure.IPLDBuilderFailure], fx.Effects, error) { - args := cap.Nb() - log := log.With(zap.String("space", cap.With())) +func NewUploadListHandler(uploadStore upload_store.Store, logger *zap.Logger) Handler { + log := logger.With(zap.String("handler", uploadcaps.ListCommand)) + return Handler{ + Capability: uploadcaps.List, + Handler: bindexec.NewHandler(func( + req *bindexec.Request[*uploadcaps.ListArguments], + res *bindexec.Response[*uploadcaps.ListOK], + ) error { + args := req.Task().BindArguments() + space := req.Invocation().Subject() + log := log.With(zap.Stringer("space", space.DID())) var opts []upload_store.ListOption if args.Size != nil { - log = log.With(zap.Uint64("size", *args.Size)) + log = log.With(zap.Int64("size", *args.Size)) opts = append(opts, upload_store.WithListLimit(int(*args.Size))) } if args.Cursor != nil { @@ -49,31 +32,24 @@ func UploadListHandler(uploadStore upload_store.Store, logger *zap.Logger) serve } log.Debug("listing uploads") - space, err := did.Parse(cap.With()) - if err != nil { - return result.Error[upload.ListOk, failure.IPLDBuilderFailure]( - errors.New(InvalidSpaceErrorName, "invalid space DID: %v", err), - ), nil, nil - } - - page, err := uploadStore.List(ctx, space, opts...) + page, err := uploadStore.List(req.Context(), space.DID(), opts...) if err != nil { - log.Error("failed to llist uploads", zap.Error(err)) - return nil, nil, fmt.Errorf("listing uploads: %w", err) + log.Error("failed to list uploads", zap.Error(err)) + return fmt.Errorf("listing uploads: %w", err) } - results := make([]upload.ListItem, 0, len(page.Results)) + results := make([]uploadcaps.ListUploadItem, 0, len(page.Results)) for _, r := range page.Results { - results = append(results, upload.ListItem{ - Root: cidlink.Link{Cid: r.Root}, - InsertedAt: r.InsertedAt, - UpdatedAt: r.UpdatedAt, + results = append(results, uploadcaps.ListUploadItem{ + Root: r.Root, + Index: r.Index, }) } - return result.Ok[upload.ListOk, failure.IPLDBuilderFailure](upload.ListOk{ + return res.SetSuccess(&uploadcaps.ListOK{ Results: results, Cursor: page.Cursor, - }), nil, nil - }) + }) + }), + } } diff --git a/pkg/service/handlers/upload_list_test.go b/pkg/service/handlers/upload_list_test.go index a3b9184..34716ad 100644 --- a/pkg/service/handlers/upload_list_test.go +++ b/pkg/service/handlers/upload_list_test.go @@ -1,19 +1,47 @@ package handlers_test import ( + "context" "testing" - cidlink "github.com/ipld/go-ipld-prime/linking/cid" - "github.com/storacha/go-libstoracha/capabilities/upload" - "github.com/storacha/go-ucanto/core/result" - "github.com/storacha/go-ucanto/core/result/failure/datamodel" + uploadcaps "github.com/fil-forge/libforge/capabilities/upload" + "github.com/fil-forge/ucantone/execution" + "github.com/fil-forge/ucantone/ipld/datamodel" + "github.com/fil-forge/ucantone/principal" + "github.com/fil-forge/ucantone/result" + "github.com/fil-forge/ucantone/ucan/invocation" + "github.com/ipfs/go-cid" "github.com/storacha/sprue/internal/testutil" "github.com/storacha/sprue/pkg/service/handlers" - uploadmemory "github.com/storacha/sprue/pkg/store/upload/memory" + upload_store "github.com/storacha/sprue/pkg/store/upload/memory" "github.com/stretchr/testify/require" "go.uber.org/zap/zaptest" ) +// invokeUploadList builds an /upload/list invocation with the given args and a +// signed response ready for the handler. +func invokeUploadList( + t *testing.T, + ctx context.Context, + agent principal.Signer, + uploadService principal.Signer, + space principal.Signer, + args *uploadcaps.ListArguments, +) (execution.Request, *execution.ExecResponse) { + t.Helper() + inv, err := uploadcaps.List.Invoke( + agent, + space, + args, + invocation.WithAudience(uploadService), + ) + require.NoError(t, err) + req := execution.NewRequest(ctx, inv) + res, err := execution.NewResponse(req.Invocation().Task().Link(), execution.WithSigner(uploadService)) + require.NoError(t, err) + return req, res +} + func TestUploadListHandler(t *testing.T) { logger := zaptest.NewLogger(t) ctx := t.Context() @@ -21,177 +49,151 @@ func TestUploadListHandler(t *testing.T) { uploadService := testutil.WebService alice := testutil.Alice - t.Run("invalid space DID", func(t *testing.T) { - store := uploadmemory.New() - handler := handlers.UploadListHandler(store, logger) - - caveats := upload.ListCaveats{} - inv, err := upload.List.Invoke(alice, uploadService, "not-a-did", caveats) - require.NoError(t, err) - - cap := upload.List.New("not-a-did", caveats) - - res, _, err := handler(ctx, cap, inv, nil) - require.NoError(t, err) - - _, fail := result.Unwrap(res) - require.NotNil(t, fail) - - model := datamodel.Bind(testutil.Must(fail.ToIPLD())(t)) - require.NotNil(t, model.Name) - require.Equal(t, handlers.InvalidSpaceErrorName, *model.Name) - }) - t.Run("empty list", func(t *testing.T) { - store := uploadmemory.New() - handler := handlers.UploadListHandler(store, logger) + store := upload_store.New() + handler := handlers.NewUploadListHandler(store, logger) space := testutil.RandomSigner(t) - caveats := upload.ListCaveats{} - inv, err := upload.List.Invoke(alice, uploadService, space.DID().String(), caveats) - require.NoError(t, err) + req, res := invokeUploadList(t, ctx, alice, uploadService, space, &uploadcaps.ListArguments{}) - cap := upload.List.New(space.DID().String(), caveats) - - res, _, err := handler(ctx, cap, inv, nil) + err := handler.Handler(req, res) require.NoError(t, err) - ok, fail := result.Unwrap(res) + o, fail := result.Unwrap(res.Receipt().Out()) require.Nil(t, fail) + require.NotNil(t, o) + + ok := uploadcaps.ListOK{} + require.NoError(t, datamodel.Rebind(datamodel.NewAny(o), &ok)) require.Empty(t, ok.Results) require.Nil(t, ok.Cursor) }) t.Run("lists uploads", func(t *testing.T) { - store := uploadmemory.New() - handler := handlers.UploadListHandler(store, logger) + store := upload_store.New() + handler := handlers.NewUploadListHandler(store, logger) space := testutil.RandomSigner(t) root1 := testutil.RandomCID(t) root2 := testutil.RandomCID(t) - err := store.Upsert(ctx, space.DID(), root1, nil, testutil.RandomCID(t)) - require.NoError(t, err) - err = store.Upsert(ctx, space.DID(), root2, nil, testutil.RandomCID(t)) - require.NoError(t, err) - - caveats := upload.ListCaveats{} - inv, err := upload.List.Invoke(alice, uploadService, space.DID().String(), caveats) - require.NoError(t, err) + require.NoError(t, store.Upsert(ctx, space.DID(), root1, nil, nil, testutil.RandomCID(t))) + require.NoError(t, store.Upsert(ctx, space.DID(), root2, nil, nil, testutil.RandomCID(t))) - cap := upload.List.New(space.DID().String(), caveats) + req, res := invokeUploadList(t, ctx, alice, uploadService, space, &uploadcaps.ListArguments{}) - res, _, err := handler(ctx, cap, inv, nil) + err := handler.Handler(req, res) require.NoError(t, err) - ok, fail := result.Unwrap(res) + o, fail := result.Unwrap(res.Receipt().Out()) require.Nil(t, fail) + ok := uploadcaps.ListOK{} + require.NoError(t, datamodel.Rebind(datamodel.NewAny(o), &ok)) require.Len(t, ok.Results, 2) - roots := make(map[string]bool) + roots := map[string]bool{} for _, item := range ok.Results { roots[item.Root.String()] = true } - require.True(t, roots[cidlink.Link{Cid: root1}.String()]) - require.True(t, roots[cidlink.Link{Cid: root2}.String()]) + require.True(t, roots[root1.String()]) + require.True(t, roots[root2.String()]) }) t.Run("with size limit", func(t *testing.T) { - store := uploadmemory.New() - handler := handlers.UploadListHandler(store, logger) + store := upload_store.New() + handler := handlers.NewUploadListHandler(store, logger) space := testutil.RandomSigner(t) - - for i := 0; i < 3; i++ { - err := store.Upsert(ctx, space.DID(), testutil.RandomCID(t), nil, testutil.RandomCID(t)) - require.NoError(t, err) + for range 3 { + require.NoError(t, store.Upsert(ctx, space.DID(), testutil.RandomCID(t), nil, nil, testutil.RandomCID(t))) } - size := uint64(2) - caveats := upload.ListCaveats{Size: &size} - inv, err := upload.List.Invoke(alice, uploadService, space.DID().String(), caveats) - require.NoError(t, err) - - cap := upload.List.New(space.DID().String(), caveats) + size := int64(2) + req, res := invokeUploadList(t, ctx, alice, uploadService, space, &uploadcaps.ListArguments{Size: &size}) - res, _, err := handler(ctx, cap, inv, nil) + err := handler.Handler(req, res) require.NoError(t, err) - ok, fail := result.Unwrap(res) + o, fail := result.Unwrap(res.Receipt().Out()) require.Nil(t, fail) + ok := uploadcaps.ListOK{} + require.NoError(t, datamodel.Rebind(datamodel.NewAny(o), &ok)) require.Len(t, ok.Results, 2) require.NotNil(t, ok.Cursor) }) t.Run("with cursor pagination", func(t *testing.T) { - store := uploadmemory.New() - handler := handlers.UploadListHandler(store, logger) + store := upload_store.New() + handler := handlers.NewUploadListHandler(store, logger) space := testutil.RandomSigner(t) + for range 3 { + require.NoError(t, store.Upsert(ctx, space.DID(), testutil.RandomCID(t), nil, nil, testutil.RandomCID(t))) + } - err := store.Upsert(ctx, space.DID(), testutil.RandomCID(t), nil, testutil.RandomCID(t)) - require.NoError(t, err) - err = store.Upsert(ctx, space.DID(), testutil.RandomCID(t), nil, testutil.RandomCID(t)) - require.NoError(t, err) - err = store.Upsert(ctx, space.DID(), testutil.RandomCID(t), nil, testutil.RandomCID(t)) - require.NoError(t, err) - - // First page: size 1 - size := uint64(1) - caveats1 := upload.ListCaveats{Size: &size} - inv1, err := upload.List.Invoke(alice, uploadService, space.DID().String(), caveats1) - require.NoError(t, err) - - cap1 := upload.List.New(space.DID().String(), caveats1) - - res1, _, err := handler(ctx, cap1, inv1, nil) - require.NoError(t, err) + size := int64(1) + req1, res1 := invokeUploadList(t, ctx, alice, uploadService, space, &uploadcaps.ListArguments{Size: &size}) + require.NoError(t, handler.Handler(req1, res1)) - ok1, fail := result.Unwrap(res1) + o1, fail := result.Unwrap(res1.Receipt().Out()) require.Nil(t, fail) + ok1 := uploadcaps.ListOK{} + require.NoError(t, datamodel.Rebind(datamodel.NewAny(o1), &ok1)) require.Len(t, ok1.Results, 1) require.NotNil(t, ok1.Cursor) - // Second page using cursor + // Second page using cursor. cursor := *ok1.Cursor - caveats2 := upload.ListCaveats{Cursor: &cursor, Size: &size} - inv2, err := upload.List.Invoke(alice, uploadService, space.DID().String(), caveats2) - require.NoError(t, err) - - cap2 := upload.List.New(space.DID().String(), caveats2) + req2, res2 := invokeUploadList(t, ctx, alice, uploadService, space, &uploadcaps.ListArguments{Cursor: &cursor, Size: &size}) + require.NoError(t, handler.Handler(req2, res2)) - res2, _, err := handler(ctx, cap2, inv2, nil) - require.NoError(t, err) - - ok2, fail := result.Unwrap(res2) + o2, fail := result.Unwrap(res2.Receipt().Out()) require.Nil(t, fail) + ok2 := uploadcaps.ListOK{} + require.NoError(t, datamodel.Rebind(datamodel.NewAny(o2), &ok2)) require.Len(t, ok2.Results, 1) - - // Results should be different require.NotEqual(t, ok1.Results[0].Root.String(), ok2.Results[0].Root.String()) }) t.Run("does not list uploads from other spaces", func(t *testing.T) { - store := uploadmemory.New() - handler := handlers.UploadListHandler(store, logger) + store := upload_store.New() + handler := handlers.NewUploadListHandler(store, logger) space1 := testutil.RandomSigner(t) space2 := testutil.RandomSigner(t) - err := store.Upsert(ctx, space1.DID(), testutil.RandomCID(t), nil, testutil.RandomCID(t)) - require.NoError(t, err) + require.NoError(t, store.Upsert(ctx, space1.DID(), testutil.RandomCID(t), nil, nil, testutil.RandomCID(t))) - caveats := upload.ListCaveats{} - inv, err := upload.List.Invoke(alice, uploadService, space2.DID().String(), caveats) - require.NoError(t, err) + // Query space2 — should be empty. + req, res := invokeUploadList(t, ctx, alice, uploadService, space2, &uploadcaps.ListArguments{}) + require.NoError(t, handler.Handler(req, res)) - cap := upload.List.New(space2.DID().String(), caveats) + o, fail := result.Unwrap(res.Receipt().Out()) + require.Nil(t, fail) + ok := uploadcaps.ListOK{} + require.NoError(t, datamodel.Rebind(datamodel.NewAny(o), &ok)) + require.Empty(t, ok.Results) + }) - res, _, err := handler(ctx, cap, inv, nil) - require.NoError(t, err) + t.Run("preserves optional index pointer", func(t *testing.T) { + store := upload_store.New() + handler := handlers.NewUploadListHandler(store, logger) - ok, fail := result.Unwrap(res) + space := testutil.RandomSigner(t) + root := testutil.RandomCID(t) + index := testutil.RandomCID(t) + require.NoError(t, store.Upsert(ctx, space.DID(), root, &index, nil, testutil.RandomCID(t))) + + req, res := invokeUploadList(t, ctx, alice, uploadService, space, &uploadcaps.ListArguments{}) + + require.NoError(t, handler.Handler(req, res)) + + o, fail := result.Unwrap(res.Receipt().Out()) require.Nil(t, fail) - require.Empty(t, ok.Results) + ok := uploadcaps.ListOK{} + require.NoError(t, datamodel.Rebind(datamodel.NewAny(o), &ok)) + require.Len(t, ok.Results, 1) + require.NotNil(t, ok.Results[0].Index) + require.Equal(t, cid.Cid(index), *ok.Results[0].Index) }) } diff --git a/pkg/service/handlers/upload_shard_list.go b/pkg/service/handlers/upload_shard_list.go index 6d7795f..ac5b64d 100644 --- a/pkg/service/handlers/upload_shard_list.go +++ b/pkg/service/handlers/upload_shard_list.go @@ -1,48 +1,31 @@ package handlers import ( - "context" "fmt" - cidlink "github.com/ipld/go-ipld-prime/linking/cid" - "github.com/storacha/go-libstoracha/capabilities/upload/shard" - "github.com/storacha/go-ucanto/core/invocation" - "github.com/storacha/go-ucanto/core/ipld" - "github.com/storacha/go-ucanto/core/receipt/fx" - "github.com/storacha/go-ucanto/core/result" - "github.com/storacha/go-ucanto/core/result/failure" - "github.com/storacha/go-ucanto/did" - "github.com/storacha/go-ucanto/server" - "github.com/storacha/go-ucanto/ucan" - "github.com/storacha/sprue/pkg/internal/ipldutil" - "github.com/storacha/sprue/pkg/lib/errors" + shardcaps "github.com/fil-forge/libforge/capabilities/upload/shard" + "github.com/fil-forge/ucantone/execution/bindexec" upload_store "github.com/storacha/sprue/pkg/store/upload" "go.uber.org/zap" ) -// WithUploadShardListMethod registers the upload/shard/list handler. // This handler lists the shards of an upload. -func WithUploadShardListMethod(uploadStore upload_store.Store, logger *zap.Logger) server.Option { - return server.WithServiceMethod( - shard.ListAbility, - server.Provide(shard.List, UploadShardListHandler(uploadStore, logger)), - ) -} - -func UploadShardListHandler(uploadStore upload_store.Store, logger *zap.Logger) server.HandlerFunc[shard.ListCaveats, shard.ListOk, failure.IPLDBuilderFailure] { - log := logger.With(zap.String("handler", shard.ListAbility)) - return server.HandlerFunc[shard.ListCaveats, shard.ListOk, failure.IPLDBuilderFailure]( - func(ctx context.Context, - cap ucan.Capability[shard.ListCaveats], - inv invocation.Invocation, - iCtx server.InvocationContext, - ) (result.Result[shard.ListOk, failure.IPLDBuilderFailure], fx.Effects, error) { - args := cap.Nb() - log := log.With(zap.String("space", cap.With()), zap.Stringer("root", args.Root)) +func NewUploadShardListHandler(uploadStore upload_store.Store, logger *zap.Logger) Handler { + log := logger.With(zap.String("handler", shardcaps.ListCommand)) + return Handler{ + Capability: shardcaps.List, + Handler: bindexec.NewHandler(func( + req *bindexec.Request[*shardcaps.ListArguments], + res *bindexec.Response[*shardcaps.ListOK], + ) error { + args := req.Task().BindArguments() + space := req.Invocation().Subject() + root := args.Root + log := log.With(zap.Stringer("space", space.DID()), zap.Stringer("root", root)) var opts []upload_store.ListShardsOption if args.Size != nil { - log = log.With(zap.Uint64("size", *args.Size)) + log = log.With(zap.Int64("size", *args.Size)) opts = append(opts, upload_store.WithListShardsLimit(int(*args.Size))) } if args.Cursor != nil { @@ -51,32 +34,16 @@ func UploadShardListHandler(uploadStore upload_store.Store, logger *zap.Logger) } log.Debug("listing upload shards") - space, err := did.Parse(cap.With()) - if err != nil { - return result.Error[shard.ListOk, failure.IPLDBuilderFailure]( - errors.New(InvalidSpaceErrorName, "invalid space DID: %v", err), - ), nil, nil - } - - root, err := ipldutil.ToCID(args.Root) - if err != nil { - return nil, nil, err - } - - page, err := uploadStore.ListShards(ctx, space, root, opts...) + page, err := uploadStore.ListShards(req.Context(), space.DID(), root, opts...) if err != nil { log.Error("failed to list upload shards", zap.Error(err)) - return nil, nil, fmt.Errorf("listing upload shards: %w", err) - } - - results := make([]ipld.Link, 0, len(page.Results)) - for _, r := range page.Results { - results = append(results, cidlink.Link{Cid: r}) + return fmt.Errorf("listing upload shards: %w", err) } - return result.Ok[shard.ListOk, failure.IPLDBuilderFailure](shard.ListOk{ - Results: results, + return res.SetSuccess(&shardcaps.ListOK{ + Results: page.Results, Cursor: page.Cursor, - }), nil, nil - }) + }) + }), + } } diff --git a/pkg/service/handlers/upload_shard_list_test.go b/pkg/service/handlers/upload_shard_list_test.go index a12838e..01780f7 100644 --- a/pkg/service/handlers/upload_shard_list_test.go +++ b/pkg/service/handlers/upload_shard_list_test.go @@ -1,20 +1,47 @@ package handlers_test import ( + "context" "testing" + shardcaps "github.com/fil-forge/libforge/capabilities/upload/shard" + "github.com/fil-forge/ucantone/execution" + "github.com/fil-forge/ucantone/ipld/datamodel" + "github.com/fil-forge/ucantone/principal" + "github.com/fil-forge/ucantone/result" + "github.com/fil-forge/ucantone/ucan/invocation" "github.com/ipfs/go-cid" - cidlink "github.com/ipld/go-ipld-prime/linking/cid" - "github.com/storacha/go-libstoracha/capabilities/upload/shard" - "github.com/storacha/go-ucanto/core/result" - "github.com/storacha/go-ucanto/core/result/failure/datamodel" "github.com/storacha/sprue/internal/testutil" "github.com/storacha/sprue/pkg/service/handlers" - uploadmemory "github.com/storacha/sprue/pkg/store/upload/memory" + upload_store "github.com/storacha/sprue/pkg/store/upload/memory" "github.com/stretchr/testify/require" "go.uber.org/zap/zaptest" ) +// invokeUploadShardList builds an /upload/shard/list invocation with the given +// args and a signed response ready for the handler. +func invokeUploadShardList( + t *testing.T, + ctx context.Context, + agent principal.Signer, + uploadService principal.Signer, + space principal.Signer, + args *shardcaps.ListArguments, +) (execution.Request, *execution.ExecResponse) { + t.Helper() + inv, err := shardcaps.List.Invoke( + agent, + space, + args, + invocation.WithAudience(uploadService), + ) + require.NoError(t, err) + req := execution.NewRequest(ctx, inv) + res, err := execution.NewResponse(req.Invocation().Task().Link(), execution.WithSigner(uploadService)) + require.NoError(t, err) + return req, res +} + func TestUploadShardListHandler(t *testing.T) { logger := zaptest.NewLogger(t) ctx := t.Context() @@ -22,128 +49,117 @@ func TestUploadShardListHandler(t *testing.T) { uploadService := testutil.WebService alice := testutil.Alice - t.Run("invalid space DID", func(t *testing.T) { - store := uploadmemory.New() - handler := handlers.UploadShardListHandler(store, logger) - - root := cidlink.Link{Cid: testutil.RandomCID(t)} - caveats := shard.ListCaveats{Root: root} - inv, err := shard.List.Invoke(alice, uploadService, "not-a-did", caveats) - require.NoError(t, err) - - cap := shard.List.New("not-a-did", caveats) - - res, _, err := handler(ctx, cap, inv, nil) - require.NoError(t, err) - - _, fail := result.Unwrap(res) - require.NotNil(t, fail) - - model := datamodel.Bind(testutil.Must(fail.ToIPLD())(t)) - require.NotNil(t, model.Name) - require.Equal(t, handlers.InvalidSpaceErrorName, *model.Name) - }) - t.Run("empty shards", func(t *testing.T) { - store := uploadmemory.New() - handler := handlers.UploadShardListHandler(store, logger) + store := upload_store.New() + handler := handlers.NewUploadShardListHandler(store, logger) space := testutil.RandomSigner(t) root := testutil.RandomCID(t) - // Create upload with no shards - err := store.Upsert(ctx, space.DID(), root, nil, testutil.RandomCID(t)) - require.NoError(t, err) + // Upload exists with no shards. + require.NoError(t, store.Upsert(ctx, space.DID(), root, nil, nil, testutil.RandomCID(t))) - rootLink := cidlink.Link{Cid: root} - caveats := shard.ListCaveats{Root: rootLink} - inv, err := shard.List.Invoke(alice, uploadService, space.DID().String(), caveats) - require.NoError(t, err) + req, res := invokeUploadShardList(t, ctx, alice, uploadService, space, &shardcaps.ListArguments{Root: root}) - cap := shard.List.New(space.DID().String(), caveats) - - res, _, err := handler(ctx, cap, inv, nil) + err := handler.Handler(req, res) require.NoError(t, err) - ok, fail := result.Unwrap(res) + o, fail := result.Unwrap(res.Receipt().Out()) require.Nil(t, fail) + require.NotNil(t, o) + + ok := shardcaps.ListOK{} + require.NoError(t, datamodel.Rebind(datamodel.NewAny(o), &ok)) require.Empty(t, ok.Results) }) t.Run("lists shards", func(t *testing.T) { - store := uploadmemory.New() - handler := handlers.UploadShardListHandler(store, logger) + store := upload_store.New() + handler := handlers.NewUploadShardListHandler(store, logger) space := testutil.RandomSigner(t) root := testutil.RandomCID(t) shard1 := testutil.RandomCID(t) shard2 := testutil.RandomCID(t) - err := store.Upsert(ctx, space.DID(), root, []cid.Cid{shard1, shard2}, testutil.RandomCID(t)) - require.NoError(t, err) - - rootLink := cidlink.Link{Cid: root} - caveats := shard.ListCaveats{Root: rootLink} - inv, err := shard.List.Invoke(alice, uploadService, space.DID().String(), caveats) - require.NoError(t, err) + require.NoError(t, store.Upsert(ctx, space.DID(), root, nil, []cid.Cid{shard1, shard2}, testutil.RandomCID(t))) - cap := shard.List.New(space.DID().String(), caveats) + req, res := invokeUploadShardList(t, ctx, alice, uploadService, space, &shardcaps.ListArguments{Root: root}) - res, _, err := handler(ctx, cap, inv, nil) + err := handler.Handler(req, res) require.NoError(t, err) - ok, fail := result.Unwrap(res) + o, fail := result.Unwrap(res.Receipt().Out()) require.Nil(t, fail) + ok := shardcaps.ListOK{} + require.NoError(t, datamodel.Rebind(datamodel.NewAny(o), &ok)) require.Len(t, ok.Results, 2) + + got := map[string]bool{} + for _, c := range ok.Results { + got[c.String()] = true + } + require.True(t, got[shard1.String()]) + require.True(t, got[shard2.String()]) }) - t.Run("with cursor pagination", func(t *testing.T) { - store := uploadmemory.New() - handler := handlers.UploadShardListHandler(store, logger) + t.Run("with size limit", func(t *testing.T) { + store := upload_store.New() + handler := handlers.NewUploadShardListHandler(store, logger) space := testutil.RandomSigner(t) root := testutil.RandomCID(t) shard1 := testutil.RandomCID(t) shard2 := testutil.RandomCID(t) shard3 := testutil.RandomCID(t) + require.NoError(t, store.Upsert(ctx, space.DID(), root, nil, []cid.Cid{shard1, shard2, shard3}, testutil.RandomCID(t))) + + size := int64(2) + req, res := invokeUploadShardList(t, ctx, alice, uploadService, space, &shardcaps.ListArguments{Root: root, Size: &size}) - err := store.Upsert(ctx, space.DID(), root, []cid.Cid{shard1, shard2, shard3}, testutil.RandomCID(t)) + err := handler.Handler(req, res) require.NoError(t, err) - rootLink := cidlink.Link{Cid: root} + o, fail := result.Unwrap(res.Receipt().Out()) + require.Nil(t, fail) + ok := shardcaps.ListOK{} + require.NoError(t, datamodel.Rebind(datamodel.NewAny(o), &ok)) + require.Len(t, ok.Results, 2) + require.NotNil(t, ok.Cursor) + }) - // First page: size 1 - size := uint64(1) - caveats1 := shard.ListCaveats{Root: rootLink, Size: &size} - inv1, err := shard.List.Invoke(alice, uploadService, space.DID().String(), caveats1) - require.NoError(t, err) + t.Run("with cursor pagination", func(t *testing.T) { + store := upload_store.New() + handler := handlers.NewUploadShardListHandler(store, logger) - cap1 := shard.List.New(space.DID().String(), caveats1) + space := testutil.RandomSigner(t) + root := testutil.RandomCID(t) + shard1 := testutil.RandomCID(t) + shard2 := testutil.RandomCID(t) + shard3 := testutil.RandomCID(t) + require.NoError(t, store.Upsert(ctx, space.DID(), root, nil, []cid.Cid{shard1, shard2, shard3}, testutil.RandomCID(t))) - res1, _, err := handler(ctx, cap1, inv1, nil) - require.NoError(t, err) + size := int64(1) + req1, res1 := invokeUploadShardList(t, ctx, alice, uploadService, space, &shardcaps.ListArguments{Root: root, Size: &size}) + require.NoError(t, handler.Handler(req1, res1)) - ok1, fail := result.Unwrap(res1) + o1, fail := result.Unwrap(res1.Receipt().Out()) require.Nil(t, fail) + ok1 := shardcaps.ListOK{} + require.NoError(t, datamodel.Rebind(datamodel.NewAny(o1), &ok1)) require.Len(t, ok1.Results, 1) require.NotNil(t, ok1.Cursor) - // Second page using cursor + // Second page using cursor. cursor := *ok1.Cursor - caveats2 := shard.ListCaveats{Root: rootLink, Cursor: &cursor, Size: &size} - inv2, err := shard.List.Invoke(alice, uploadService, space.DID().String(), caveats2) - require.NoError(t, err) + req2, res2 := invokeUploadShardList(t, ctx, alice, uploadService, space, &shardcaps.ListArguments{Root: root, Cursor: &cursor, Size: &size}) + require.NoError(t, handler.Handler(req2, res2)) - cap2 := shard.List.New(space.DID().String(), caveats2) - - res2, _, err := handler(ctx, cap2, inv2, nil) - require.NoError(t, err) - - ok2, fail := result.Unwrap(res2) + o2, fail := result.Unwrap(res2.Receipt().Out()) require.Nil(t, fail) + ok2 := shardcaps.ListOK{} + require.NoError(t, datamodel.Rebind(datamodel.NewAny(o2), &ok2)) require.Len(t, ok2.Results, 1) - - // Results should be different require.NotEqual(t, ok1.Results[0].String(), ok2.Results[0].String()) }) } diff --git a/pkg/service/service.go b/pkg/service/service.go index 591c18b..4052824 100644 --- a/pkg/service/service.go +++ b/pkg/service/service.go @@ -2,37 +2,25 @@ package service import ( "bytes" - "context" "errors" "fmt" - "io" "net/http" "slices" + "github.com/fil-forge/ucantone/ipld/codec/dagcbor" + "github.com/fil-forge/ucantone/principal" + "github.com/fil-forge/ucantone/server" + "github.com/fil-forge/ucantone/ucan/container" + "github.com/fil-forge/ucantone/validator" "github.com/ipfs/go-cid" "github.com/labstack/echo/v4" - "github.com/storacha/go-libstoracha/capabilities/access" - "github.com/storacha/go-ucanto/core/car" - "github.com/storacha/go-ucanto/core/dag/blockstore" - "github.com/storacha/go-ucanto/core/delegation" - "github.com/storacha/go-ucanto/core/ipld" - "github.com/storacha/go-ucanto/core/message" - "github.com/storacha/go-ucanto/core/receipt" - "github.com/storacha/go-ucanto/core/result" - "github.com/storacha/go-ucanto/server" - ucanhttp "github.com/storacha/go-ucanto/transport/http" - "github.com/storacha/go-ucanto/ucan" - "github.com/storacha/go-ucanto/validator" - "go.uber.org/zap" - "github.com/storacha/sprue/pkg/identity" - "github.com/storacha/sprue/pkg/indexerclient" - "github.com/storacha/sprue/pkg/lib/didmailto" - "github.com/storacha/sprue/pkg/lib/ucans" + "github.com/storacha/sprue/pkg/lib/ucan_server" "github.com/storacha/sprue/pkg/service/handlers" "github.com/storacha/sprue/pkg/service/ui" "github.com/storacha/sprue/pkg/store/agent" delegation_store "github.com/storacha/sprue/pkg/store/delegation" + "go.uber.org/zap" ) // Service implements the sprue upload service logic. @@ -40,118 +28,46 @@ type Service struct { identity *identity.Identity agentStore agent.Store delegationStore delegation_store.Store - indexerClient *indexerclient.Client logger *zap.Logger - ucanServer server.ServerView[server.Service] - options []server.Option + ucanServer *server.HTTPServer } // New creates a new Service instance. -func New(id *identity.Identity, agentStore agent.Store, delegationStore delegation_store.Store, indexerClient *indexerclient.Client, logger *zap.Logger, options ...server.Option) (*Service, error) { - svc := &Service{ +func New(id *identity.Identity, agentStore agent.Store, delegationStore delegation_store.Store, handlers []handlers.Handler, logger *zap.Logger, options ...server.HTTPOption) *Service { + return &Service{ identity: id, agentStore: agentStore, delegationStore: delegationStore, - indexerClient: indexerClient, logger: logger, - options: options, + ucanServer: createUCANServer(id.Signer, agentStore, handlers, logger, options...), } - - // Create UCAN server with handlers - ucanSrv, err := svc.createUCANServer() - if err != nil { - return nil, fmt.Errorf("failed to create UCAN server: %w", err) - } - svc.ucanServer = ucanSrv - - return svc, nil } // createUCANServer creates the UCAN RPC server with registered handlers. -func (s *Service) createUCANServer() (server.ServerView[server.Service], error) { - log := s.logger - options := append( - slices.Clone(s.options), - server.WithErrorHandler(func(err server.HandlerExecutionError[any]) { - if stack := err.Stack(); stack != "" { - log = log.With(zap.String("stack", stack)) - } - log.Error("handler execution", zap.Error(err)) - }), +func createUCANServer(id principal.Signer, agentStore agent.Store, handlers []handlers.Handler, logger *zap.Logger, options ...server.HTTPOption) *server.HTTPServer { + options = append( + slices.Clone(options), + server.WithReceiptTimestamps(true), + server.WithEventListener(ucan_server.AgentMessageLogger{Logger: logger, AgentStore: agentStore}), + server.WithEventListener(ucan_server.ErrorHandler{Logger: logger}), + server.WithValidationOptions( + validator.WithPrincipalParser(ucan_server.PrincipalParser), + validator.WithNonStandardSignatureVerifier( + ucan_server.NewAttestationVerifier(id.Verifier()), + ), + ), ) - return server.NewServer(s.identity.Signer, options...) + srv := server.NewHTTP(id, options...) + for _, h := range handlers { + srv.Handle(h.Capability, h.Handler) + } + return srv } // HandleUCANRequest handles incoming UCAN RPC requests. func (s *Service) HandleUCANRequest(c echo.Context) error { - r := c.Request() - - inBytes, inMsg, inIdx, err := decodeAndIndex(r.Body) - if err != nil { - return fmt.Errorf("decoding and indexing incoming agent message: %w", err) - } - r.Body.Close() - - err = s.agentStore.Write(r.Context(), inMsg, inIdx, inBytes) - if err != nil { - return fmt.Errorf("writing incoming agent message to agent store: %w", err) - } - - res, err := s.ucanServer.Request(r.Context(), ucanhttp.NewRequest(bytes.NewReader(inBytes), r.Header)) - if err != nil { - s.logger.Error("UCAN request error", zap.Error(err)) - return fmt.Errorf("handling UCAN request: %w", err) - } - - outBytes, outMsg, outIdx, err := decodeAndIndex(res.Body()) - if err != nil { - return fmt.Errorf("decoding and indexing outgoing agent message: %w", err) - } - res.Body().Close() - - err = s.agentStore.Write(r.Context(), outMsg, outIdx, outBytes) - if err != nil { - return fmt.Errorf("writing outgoing agent message to agent store: %w", err) - } - - // Copy response headers - for key, vals := range res.Headers() { - for _, v := range vals { - c.Response().Header().Add(key, v) - } - } - - return c.Stream(res.Status(), "", bytes.NewReader(outBytes)) -} - -func decodeAndIndex(r io.Reader) ([]byte, message.AgentMessage, []agent.IndexEntry, error) { - body, err := io.ReadAll(r) - if err != nil { - return nil, nil, nil, fmt.Errorf("reading request body: %w", err) - } - roots, blocks, err := car.Decode(bytes.NewReader(body)) - if err != nil { - return nil, nil, nil, fmt.Errorf("decoding CAR: %w", err) - } - if len(roots) != 1 { - return nil, nil, nil, fmt.Errorf("expected exactly one root in CAR, got %d", len(roots)) - } - br, err := blockstore.NewBlockReader(blockstore.WithBlocksIterator(blocks)) - if err != nil { - return nil, nil, nil, fmt.Errorf("creating block reader: %w", err) - } - msg, err := message.NewMessage(roots[0], br) - if err != nil { - return nil, nil, nil, fmt.Errorf("creating agent message: %w", err) - } - var entries []agent.IndexEntry - for ent, err := range agent.Index(msg) { - if err != nil { - return nil, nil, nil, fmt.Errorf("indexing agent message: %w", err) - } - entries = append(entries, ent) - } - return body, msg, entries, nil + s.ucanServer.ServeHTTP(c.Response(), c.Request()) + return nil } func (s *Service) HandleValidateEmailRequest(c echo.Context) error { @@ -171,7 +87,7 @@ func (s *Service) HandleValidateEmailRequest(c echo.Context) error { } return c.Stream(http.StatusOK, "text/html", r) case http.MethodPost: - res, err := s.authorize(c.Request().Context(), c.QueryParam("ucan")) + res, err := ucan_server.ExecBase64urlAccessConfirm(c.Request().Context(), s.ucanServer, c.QueryParam("ucan")) if err != nil { s.logger.Error("authorization error", zap.Error(err)) r, err := ui.ErrorPage(fmt.Sprintf("Oops, something went wrong: %s", err.Error())) @@ -190,72 +106,6 @@ func (s *Service) HandleValidateEmailRequest(c echo.Context) error { } } -type authorizationResult struct { - Email string - Audience string - UCAN string - Facts []ucan.Fact -} - -func (s *Service) authorize(ctx context.Context, ucan string) (authorizationResult, error) { - dlgs, err := ucans.ParseDelegations(ucan) - if err != nil { - return authorizationResult{}, fmt.Errorf("parsing delegations: %w", err) - } - if len(dlgs) != 1 { - return authorizationResult{}, fmt.Errorf("unexpected number of delegations found in UCAN") - } - confirmation := dlgs[0] - - confirm := server.Provide( - access.Confirm, - handlers.AccessConfirmHandler(s.identity, s.delegationStore, s.logger), - ) - txn, err := confirm(ctx, confirmation, s.ucanServer.Context()) - if err != nil { - return authorizationResult{}, fmt.Errorf("executing access/confirm handler: %w", err) - } - o, x := result.Unwrap(txn.Out()) - if x != nil { - return authorizationResult{}, fmt.Errorf("access/confirm invocation failure: %w", x) - } - - // Extract the email and audience from the confirmation invocation. - // This should match since we just successfully invoked the handler. - match, err := access.Confirm.Match(validator.NewSource(confirmation.Capabilities()[0], confirmation)) - if err != nil { - return authorizationResult{}, fmt.Errorf("matching access/confirm capability: %w", err) - } - email, err := didmailto.Email(match.Value().Nb().Iss) - if err != nil { - return authorizationResult{}, fmt.Errorf("parsing account DID: %w", err) - } - - var confirmDlgs []delegation.Delegation - for _, bytes := range o.Delegations.Values { - dlgs, err := ucans.ExtractDelegations(bytes) - if err != nil { - return authorizationResult{}, fmt.Errorf("extracting delegations from confirmation result: %w", err) - } - if len(dlgs) != 1 { - return authorizationResult{}, fmt.Errorf("unexpected number of delegations found in confirmation result") - } - confirmDlgs = append(confirmDlgs, dlgs[0]) - } - - ucan, err = ucans.FormatDelegations(confirmDlgs...) - if err != nil { - return authorizationResult{}, fmt.Errorf("formatting delegations: %w", err) - } - - return authorizationResult{ - Email: email, - Audience: match.Value().Nb().Aud.String(), - UCAN: ucan, - Facts: confirmation.Facts(), - }, nil -} - // HandleReceiptRequest handles receipt retrieval requests. func (s *Service) HandleReceiptRequest(c echo.Context) error { task, err := cid.Parse(c.Param("cid")) @@ -276,15 +126,11 @@ func (s *Service) HandleReceiptRequest(c echo.Context) error { return fmt.Errorf("getting receipt: %w", err) } - // Build an agent message containing the receipt - msg, err := message.Build(nil, []receipt.AnyReceipt{rcpt}) - if err != nil { - s.logger.Error("failed to build message", zap.Error(err)) - return c.JSON(http.StatusInternalServerError, map[string]string{ - "error": "failed to build message", - }) + ct := container.New(container.WithReceipts(rcpt)) + var buf bytes.Buffer + if err := ct.MarshalCBOR(&buf); err != nil { + return fmt.Errorf("marshaling receipt container: %w", err) } - reader := car.Encode([]ipld.Link{msg.Root().Link()}, msg.Blocks()) - return c.Stream(http.StatusOK, car.ContentType, reader) + return c.Blob(http.StatusOK, dagcbor.ContentType, buf.Bytes()) } diff --git a/pkg/store/agent/agent.go b/pkg/store/agent/agent.go index b0a73ce..d67688f 100644 --- a/pkg/store/agent/agent.go +++ b/pkg/store/agent/agent.go @@ -3,11 +3,9 @@ package agent import ( "context" + "github.com/fil-forge/ucantone/errors" + "github.com/fil-forge/ucantone/ucan" "github.com/ipfs/go-cid" - "github.com/storacha/go-ucanto/core/invocation" - "github.com/storacha/go-ucanto/core/message" - "github.com/storacha/go-ucanto/core/receipt" - "github.com/storacha/sprue/pkg/lib/errors" ) const ( @@ -24,14 +22,12 @@ var ( type InvocationSource struct { Task cid.Cid - Invocation invocation.Invocation - Message cid.Cid + Invocation ucan.Invocation } type ReceiptSource struct { Task cid.Cid - Receipt receipt.AnyReceipt - Message cid.Cid + Receipt ucan.Receipt } // IndexEntry is either an indexed invocation OR an indexed receipt. @@ -42,9 +38,9 @@ type IndexEntry struct { type Store interface { // Write an agent message to the store. - Write(ctx context.Context, message message.AgentMessage, index []IndexEntry, source []byte) error + Write(ctx context.Context, message ucan.Container, index []IndexEntry) error // GetInvocation retrieves an invocation by its task CID. May return [ErrInvocationNotFound]. - GetInvocation(ctx context.Context, task cid.Cid) (invocation.Invocation, error) + GetInvocation(ctx context.Context, task cid.Cid) (ucan.Invocation, error) // GetReceipt retrieves a receipt by its task CID. May return [ErrReceiptNotFound]. - GetReceipt(ctx context.Context, task cid.Cid) (receipt.AnyReceipt, error) + GetReceipt(ctx context.Context, task cid.Cid) (ucan.Receipt, error) } diff --git a/pkg/store/agent/agent_test.go b/pkg/store/agent/agent_test.go index e60b5de..f16fe82 100644 --- a/pkg/store/agent/agent_test.go +++ b/pkg/store/agent/agent_test.go @@ -2,22 +2,18 @@ package agent_test import ( "context" - "io" "runtime" "testing" + ucancap "github.com/fil-forge/libforge/capabilities/ucan" + "github.com/fil-forge/ucantone/ipld" + "github.com/fil-forge/ucantone/ipld/datamodel" + "github.com/fil-forge/ucantone/result" + "github.com/fil-forge/ucantone/ucan" + "github.com/fil-forge/ucantone/ucan/container" + "github.com/fil-forge/ucantone/ucan/invocation" + "github.com/fil-forge/ucantone/ucan/receipt" "github.com/google/uuid" - cidlink "github.com/ipld/go-ipld-prime/linking/cid" - ucancap "github.com/storacha/go-libstoracha/capabilities/ucan" - "github.com/storacha/go-ucanto/core/car" - "github.com/storacha/go-ucanto/core/invocation" - "github.com/storacha/go-ucanto/core/ipld" - "github.com/storacha/go-ucanto/core/message" - "github.com/storacha/go-ucanto/core/receipt" - "github.com/storacha/go-ucanto/core/receipt/ran" - "github.com/storacha/go-ucanto/core/result" - "github.com/storacha/go-ucanto/core/result/ok" - "github.com/storacha/go-ucanto/ucan" "github.com/storacha/sprue/internal/testutil" "github.com/storacha/sprue/pkg/store/agent" "github.com/storacha/sprue/pkg/store/agent/aws" @@ -75,45 +71,38 @@ func createAWSStore(t *testing.T) agent.Store { return store } -func makeInvocation(t *testing.T) invocation.Invocation { +func makeInvocation(t *testing.T) ucan.Invocation { t.Helper() inv, err := invocation.Invoke( testutil.Alice, - testutil.Bob, - ucan.NewCapability("test/invoke", testutil.Alice.DID().String(), ucan.NoCaveats{}), + testutil.Alice, + "/test/invoke", + datamodel.Map{}, + invocation.WithAudience(testutil.Bob), ) require.NoError(t, err) return inv } -func makeReceipt(t *testing.T, inv invocation.Invocation) receipt.AnyReceipt { +func makeReceipt(t *testing.T, inv ucan.Invocation) ucan.Receipt { t.Helper() rcpt, err := receipt.Issue( testutil.Alice, - result.Ok[ok.Unit, ipld.Builder](ok.Unit{}), - ran.FromInvocation(inv), + inv.Task().Link(), + result.OK[ipld.Any, ipld.Any](datamodel.Map{}), ) require.NoError(t, err) return rcpt } -func buildAndWrite(t *testing.T, store agent.Store, invocations []invocation.Invocation, receipts []receipt.AnyReceipt) { +func buildAndWrite(t *testing.T, store agent.Store, invocations []ucan.Invocation, receipts []ucan.Receipt) { t.Helper() - msg, err := message.Build(invocations, receipts) - require.NoError(t, err) - - carReader := car.Encode([]ipld.Link{msg.Root().Link()}, msg.Blocks()) - - source, err := io.ReadAll(carReader) - require.NoError(t, err) - - var index []agent.IndexEntry - for entry, err := range agent.Index(msg) { - require.NoError(t, err) - index = append(index, entry) - } - - err = store.Write(t.Context(), msg, index, source) + msg := container.New( + container.WithInvocations(invocations...), + container.WithReceipts(receipts...), + ) + index := agent.Index(msg) + err := store.Write(t.Context(), msg, index) require.NoError(t, err) } @@ -124,9 +113,9 @@ func TestAgentStore(t *testing.T) { t.Run("gets an invocation", func(t *testing.T) { inv := makeInvocation(t) - buildAndWrite(t, store, []invocation.Invocation{inv}, nil) + buildAndWrite(t, store, []ucan.Invocation{inv}, nil) - got, err := store.GetInvocation(t.Context(), inv.Link().(cidlink.Link).Cid) + got, err := store.GetInvocation(t.Context(), inv.Task().Link()) require.NoError(t, err) require.Equal(t, inv.Link().String(), got.Link().String()) }) @@ -139,11 +128,11 @@ func TestAgentStore(t *testing.T) { t.Run("gets a receipt", func(t *testing.T) { inv := makeInvocation(t) rcpt := makeReceipt(t, inv) - buildAndWrite(t, store, nil, []receipt.AnyReceipt{rcpt}) + buildAndWrite(t, store, nil, []ucan.Receipt{rcpt}) - got, err := store.GetReceipt(t.Context(), inv.Link().(cidlink.Link).Cid) + got, err := store.GetReceipt(t.Context(), inv.Task().Link()) require.NoError(t, err) - require.Equal(t, rcpt.Root().Link().String(), got.Root().Link().String()) + require.Equal(t, rcpt.Link().String(), got.Link().String()) }) t.Run("returns not found for missing receipt", func(t *testing.T) { @@ -158,38 +147,30 @@ func TestAgentStore(t *testing.T) { // Create a ucan/conclude invocation that carries the receipt as its // nb.receipt caveat. This is how agents communicate receipts in-band. - concludeInv, err := invocation.Invoke( + + concludeInv, err := ucancap.Conclude.Invoke( + testutil.Alice, testutil.Alice, - testutil.Bob, - ucan.NewCapability( - ucancap.ConcludeAbility, - testutil.Alice.DID().String(), - ucancap.ConcludeCaveats{Receipt: rcpt.Root().Link()}, - ), + &ucancap.ConcludeArguments{ + Receipt: rcpt.Link(), + }, + invocation.WithAudience(testutil.Bob), ) require.NoError(t, err) - // The indexer resolves the receipt link against the message blockstore, - // so the receipt blocks must travel with the conclude invocation. Attach - // them directly so they are included when the message is built. - for blk, err := range rcpt.Blocks() { - require.NoError(t, err) - require.NoError(t, concludeInv.Attach(blk)) - } - // The receipt is now retrievable by the original task invocation CID. - buildAndWrite(t, store, []invocation.Invocation{concludeInv}, nil) - got, err := store.GetReceipt(t.Context(), taskInv.Link().(cidlink.Link).Cid) + buildAndWrite(t, store, []ucan.Invocation{concludeInv}, []ucan.Receipt{rcpt}) + got, err := store.GetReceipt(t.Context(), taskInv.Task().Link()) require.NoError(t, err) - require.Equal(t, rcpt.Root().Link().String(), got.Root().Link().String()) + require.Equal(t, rcpt.Link().String(), got.Link().String()) }) t.Run("writes invocation and receipt in the same message", func(t *testing.T) { inv := makeInvocation(t) rcpt := makeReceipt(t, inv) - buildAndWrite(t, store, []invocation.Invocation{inv}, []receipt.AnyReceipt{rcpt}) + buildAndWrite(t, store, []ucan.Invocation{inv}, []ucan.Receipt{rcpt}) - task := inv.Link().(cidlink.Link).Cid + task := inv.Task().Link() gotInv, err := store.GetInvocation(t.Context(), task) require.NoError(t, err) @@ -197,7 +178,7 @@ func TestAgentStore(t *testing.T) { gotRcpt, err := store.GetReceipt(t.Context(), task) require.NoError(t, err) - require.Equal(t, rcpt.Root().Link().String(), gotRcpt.Root().Link().String()) + require.Equal(t, rcpt.Link().String(), gotRcpt.Link().String()) }) }) } diff --git a/pkg/store/agent/aws/store.go b/pkg/store/agent/aws/store.go index 9964d55..a76260b 100644 --- a/pkg/store/agent/aws/store.go +++ b/pkg/store/agent/aws/store.go @@ -14,15 +14,12 @@ import ( "github.com/aws/aws-sdk-go-v2/service/dynamodb" "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" "github.com/aws/aws-sdk-go-v2/service/s3" + "github.com/fil-forge/libforge/jobqueue" + "github.com/fil-forge/ucantone/ipld/codec/dagcbor" + "github.com/fil-forge/ucantone/ucan" + "github.com/fil-forge/ucantone/ucan/container" cid "github.com/ipfs/go-cid" - cidlink "github.com/ipld/go-ipld-prime/linking/cid" - "github.com/storacha/go-libstoracha/jobqueue" - "github.com/storacha/go-ucanto/core/car" - "github.com/storacha/go-ucanto/core/dag/blockstore" - "github.com/storacha/go-ucanto/core/invocation" - "github.com/storacha/go-ucanto/core/message" - "github.com/storacha/go-ucanto/core/receipt" - "github.com/storacha/sprue/pkg/internal/ipldutil" + "github.com/multiformats/go-multihash" "github.com/storacha/sprue/pkg/store/agent" ) @@ -138,25 +135,34 @@ func (s *Store) Initialize(ctx context.Context) error { return nil } -func (s *Store) GetInvocation(ctx context.Context, task cid.Cid) (invocation.Invocation, error) { - root, bs, err := s.getByTask(ctx, task, "in") +func (s *Store) GetInvocation(ctx context.Context, task cid.Cid) (ucan.Invocation, error) { + _, ct, err := s.getByTask(ctx, task, "in") if err != nil { return nil, fmt.Errorf("getting invocation for task %s: %w", task, err) } - return invocation.NewInvocationView(cidlink.Link{Cid: root}, bs) + for _, inv := range ct.Invocations() { + if inv.Task().Link() == task { + return inv, nil + } + } + return nil, agent.ErrInvocationNotFound } -func (s *Store) GetReceipt(ctx context.Context, task cid.Cid) (receipt.AnyReceipt, error) { - root, bs, err := s.getByTask(ctx, task, "out") +func (s *Store) GetReceipt(ctx context.Context, task cid.Cid) (ucan.Receipt, error) { + _, ct, err := s.getByTask(ctx, task, "out") if err != nil { return nil, fmt.Errorf("getting receipt for task %s: %w", task, err) } - return receipt.NewAnyReceipt(cidlink.Link{Cid: root}, bs) + rcpt, ok := ct.Receipt(task) + if !ok { + return nil, agent.ErrReceiptNotFound + } + return rcpt, nil } -// getByTask is a helper method that retrieves the invocation or receipt root +// getByTask is a helper method that retrieves the invocation or receipt // CID and blocks for a given task CID and kind ("in" for invocation or "out" for receipt). -func (s *Store) getByTask(ctx context.Context, task cid.Cid, kind string) (cid.Cid, blockstore.BlockReader, error) { +func (s *Store) getByTask(ctx context.Context, task cid.Cid, kind string) (cid.Cid, *container.Container, error) { taskkind := fmt.Sprintf("%s.%s", task, kind) queryInput := &dynamodb.QueryInput{ TableName: &s.tableName, @@ -211,29 +217,40 @@ func (s *Store) getByTask(ctx context.Context, task cid.Cid, kind string) (cid.C } defer getRes.Body.Close() - _, blocks, err := car.Decode(getRes.Body) - if err != nil { - return cid.Undef, nil, fmt.Errorf("decoding CAR: %w", err) + var ct container.Container + if err := ct.UnmarshalCBOR(getRes.Body); err != nil { + return cid.Undef, nil, fmt.Errorf("unmarshaling agent message from CBOR: %w", err) } - bs, err := blockstore.NewBlockStore(blockstore.WithBlocksIterator(blocks)) - if err != nil { - return cid.Undef, nil, fmt.Errorf("creating blockstore: %w", err) - } - return root, bs, nil + + return root, &ct, nil } func (s *Store) Shutdown(ctx context.Context) error { return s.writeQueue.Shutdown(ctx) } -func (s *Store) Write(ctx context.Context, message message.AgentMessage, index []agent.IndexEntry, source []byte) error { +func (s *Store) Write(ctx context.Context, message ucan.Container, index []agent.IndexEntry) error { var wg sync.WaitGroup var writeErrMutex sync.Mutex var writeErr error - msgRoot, err := ipldutil.ToCID(message.Root().Link()) + c, ok := message.(*container.Container) + if !ok { + c = container.New( + container.WithInvocations(message.Invocations()...), + container.WithReceipts(message.Receipts()...), + container.WithDelegations(message.Delegations()...), + ) + } + + var buf bytes.Buffer + if err := c.MarshalCBOR(&buf); err != nil { + return fmt.Errorf("marshaling agent message to CBOR: %w", err) + } + + msgRoot, err := cid.V1Builder{Codec: dagcbor.Code, MhType: multihash.SHA2_256}.Sum(buf.Bytes()) if err != nil { - return fmt.Errorf("converting message root link to CID: %w", err) + return fmt.Errorf("hashing agent message: %w", err) } callback := func(err error) { @@ -250,7 +267,7 @@ func (s *Store) Write(ctx context.Context, message message.AgentMessage, index [ s3Put: &s3.PutObjectInput{ Bucket: &s.bucketName, Key: aws.String(toMessagePath(msgRoot)), - Body: bytes.NewReader(source), + Body: &buf, }, callback: callback, }) @@ -260,10 +277,7 @@ func (s *Store) Write(ctx context.Context, message message.AgentMessage, index [ for _, entry := range index { if entry.Invocation != nil { - invRoot, err := ipldutil.ToCID(entry.Invocation.Invocation.Link()) - if err != nil { - return fmt.Errorf("converting invocation root link to CID: %w", err) - } + invRoot := entry.Invocation.Invocation.Link() wg.Add(1) err = s.writeQueue.Queue(ctx, awsWriteJob{ @@ -278,10 +292,7 @@ func (s *Store) Write(ctx context.Context, message message.AgentMessage, index [ } } if entry.Receipt != nil { - rcptRoot, err := ipldutil.ToCID(entry.Receipt.Receipt.Root().Link()) - if err != nil { - return fmt.Errorf("converting receipt root link to CID: %w", err) - } + rcptRoot := entry.Receipt.Receipt.Link() wg.Add(1) err = s.writeQueue.Queue(ctx, awsWriteJob{ diff --git a/pkg/store/agent/index.go b/pkg/store/agent/index.go index 96dd1f4..b3d6827 100644 --- a/pkg/store/agent/index.go +++ b/pkg/store/agent/index.go @@ -1,239 +1,26 @@ package agent -import ( - "fmt" - "iter" - - "github.com/ipfs/go-cid" - logging "github.com/ipfs/go-log/v2" - "github.com/ipld/go-ipld-prime" - cidlink "github.com/ipld/go-ipld-prime/linking/cid" - "github.com/storacha/go-libstoracha/capabilities/ucan" - "github.com/storacha/go-ucanto/core/dag/blockstore" - "github.com/storacha/go-ucanto/core/invocation" - "github.com/storacha/go-ucanto/core/message" - "github.com/storacha/go-ucanto/core/receipt" - "github.com/storacha/go-ucanto/core/receipt/fx" - "github.com/storacha/go-ucanto/validator" -) - -var log = logging.Logger("store/agent") - -type member struct { - invocation invocation.Invocation - receipt receipt.AnyReceipt -} - -// Iterates all embedded invocations & receipts of the given invocation. -func iterateInvocation(source cid.Cid, blocks blockstore.BlockReader, inv invocation.Invocation) iter.Seq2[member, error] { - return func(yield func(member, error) bool) { - caps := inv.Capabilities() - if len(caps) > 0 && caps[0].Can() == ucan.ConcludeAbility { - var err error - match, err := ucan.Conclude.Match(validator.NewSource(caps[0], inv)) - if err != nil { - log.Warnw("invalid invocation", "can", ucan.ConcludeAbility, "source", source, "error", err) - return - } - rcpt, err := receipt.NewAnyReceipt(match.Value().Nb().Receipt, blocks) - if err != nil { - log.Warnw("creating receipt", "source", source, "error", err) - return - } - if !yield(member{receipt: rcpt}, nil) { - return - } - for m, err := range iterateReceipt(source, blocks, rcpt) { - if err != nil { - yield(member{}, err) - return - } - if !yield(m, nil) { - return - } - } - } - } -} - -// Iterates all embedded invocations & receipts of the given receipt. -func iterateReceipt(source cid.Cid, blocks blockstore.BlockReader, rcpt receipt.AnyReceipt) iter.Seq2[member, error] { - return func(yield func(member, error) bool) { - invs := []invocation.Invocation{} - inv, ok := rcpt.Ran().Invocation() - if ok { - invs = append(invs, inv) - } - - if rcpt.Fx() != nil { - for _, fx := range rcpt.Fx().Fork() { - inv, ok := fx.Invocation() - if ok { - invs = append(invs, inv) - } - } - if rcpt.Fx().Join() != (fx.Effect{}) { - inv, ok := rcpt.Fx().Join().Invocation() - if ok { - invs = append(invs, inv) - } - } - } - - for _, inv := range invs { - if !yield(member{invocation: inv}, nil) { - return - } - for m, err := range iterateInvocation(source, blocks, inv) { - if err != nil { - yield(member{}, err) - return - } - if !yield(m, nil) { - return - } - } - } +import "github.com/fil-forge/ucantone/ucan" + +func Index(message ucan.Container) []IndexEntry { + var entries []IndexEntry + for _, inv := range message.Invocations() { + entry := IndexEntry{ + Invocation: &InvocationSource{ + Task: inv.Task().Link(), + Invocation: inv, + }, + } + entries = append(entries, entry) } -} - -func Index(message message.AgentMessage) iter.Seq2[IndexEntry, error] { - return func(yield func(IndexEntry, error) bool) { - source, err := toCID(message.Root().Link()) - if err != nil { - yield(IndexEntry{}, fmt.Errorf("converting message root link to CID: %w", err)) - return - } - - blocks, err := blockstore.NewBlockReader(blockstore.WithBlocksIterator(message.Blocks())) - if err != nil { - yield(IndexEntry{}, err) - return - } - for _, root := range message.Invocations() { - inv, err := invocation.NewInvocationView(root, blocks) - if err != nil { - log.Warnw("creating invocation", "source", source, "error", err) - continue - } - task, err := toCID(root) - if err != nil { - log.Warnw("converting invocation link to CID", "source", source, "link", root, "error", err) - continue - } - entry := IndexEntry{ - Invocation: &InvocationSource{ - Task: task, - Invocation: inv, - Message: source, - }, - } - if !yield(entry, nil) { - return - } - - for m, err := range iterateInvocation(source, blocks, inv) { - if err != nil { - yield(IndexEntry{}, err) - return - } - var entry IndexEntry - if m.invocation != nil { - task, err := toCID(m.invocation.Link()) - if err != nil { - log.Warnw("converting invocation link to CID", "source", source, "link", m.invocation.Link(), "error", err) - continue - } - entry.Invocation = &InvocationSource{ - Task: task, - Invocation: m.invocation, - Message: source, - } - } else if m.receipt != nil { - task, err := toCID(m.receipt.Ran().Link()) - if err != nil { - log.Warnw("converting receipt link to CID", "source", source, "link", m.receipt.Ran().Link(), "error", err) - continue - } - entry.Receipt = &ReceiptSource{ - Task: task, - Receipt: m.receipt, - Message: source, - } - } else { - yield(IndexEntry{}, fmt.Errorf("unexpected member with neither invocation nor receipt: %v", m)) - return - } - if !yield(entry, nil) { - return - } - } - } - - for _, root := range message.Receipts() { - rcpt, err := receipt.NewAnyReceipt(root, blocks) - if err != nil { - log.Warnw("creating receipt", "source", source, "error", err) - continue - } - task, err := toCID(rcpt.Ran().Link()) - if err != nil { - log.Warnw("converting receipt ran link to CID", "source", source, "link", rcpt.Ran().Link(), "error", err) - continue - } - entry := IndexEntry{ - Receipt: &ReceiptSource{ - Task: task, - Receipt: rcpt, - Message: source, - }, - } - if !yield(entry, nil) { - return - } - for m, err := range iterateReceipt(source, blocks, rcpt) { - if err != nil { - yield(IndexEntry{}, err) - return - } - var entry IndexEntry - if m.invocation != nil { - task, err := toCID(m.invocation.Link()) - if err != nil { - log.Warnw("converting invocation link to CID", "source", source, "link", m.invocation.Link(), "error", err) - continue - } - entry.Invocation = &InvocationSource{ - Task: task, - Invocation: m.invocation, - Message: source, - } - } else if m.receipt != nil { - task, err := toCID(m.receipt.Ran().Link()) - if err != nil { - log.Warnw("converting receipt link to CID", "source", source, "link", m.receipt.Ran().Link(), "error", err) - continue - } - entry.Receipt = &ReceiptSource{ - Task: task, - Receipt: m.receipt, - Message: source, - } - } else { - yield(IndexEntry{}, fmt.Errorf("unexpected member with neither invocation nor receipt: %v", m)) - return - } - if !yield(entry, nil) { - return - } - } - } - } -} - -func toCID(l ipld.Link) (cid.Cid, error) { - if c, ok := l.(cidlink.Link); ok { - return c.Cid, nil + for _, rcpt := range message.Receipts() { + entry := IndexEntry{ + Receipt: &ReceiptSource{ + Task: rcpt.Ran(), + Receipt: rcpt, + }, + } + entries = append(entries, entry) } - return cid.Parse(l.String()) + return entries } diff --git a/pkg/store/agent/memory/store.go b/pkg/store/agent/memory/store.go index 2a8a6a3..fa212a3 100644 --- a/pkg/store/agent/memory/store.go +++ b/pkg/store/agent/memory/store.go @@ -1,51 +1,37 @@ package memory import ( + "bytes" "context" "fmt" "sync" + "github.com/fil-forge/ucantone/ipld/codec/dagcbor" + "github.com/fil-forge/ucantone/ucan" + "github.com/fil-forge/ucantone/ucan/container" "github.com/ipfs/go-cid" - cidlink "github.com/ipld/go-ipld-prime/linking/cid" - "github.com/storacha/go-ucanto/core/dag/blockstore" - "github.com/storacha/go-ucanto/core/invocation" - "github.com/storacha/go-ucanto/core/ipld" - "github.com/storacha/go-ucanto/core/message" - "github.com/storacha/go-ucanto/core/receipt" - "github.com/storacha/sprue/pkg/internal/ipldutil" + "github.com/multiformats/go-multihash" "github.com/storacha/sprue/pkg/store/agent" ) -type carModel struct { - roots []cid.Cid - blocks []ipld.Block -} - -type indexModel struct { - // the root CID of the invocation or receipt - root cid.Cid - // the agent message this invocation or receipt was found in - at cid.Cid -} - type Store struct { mutex sync.RWMutex - // agent message CID -> carModel - store map[cid.Cid]carModel - // "///" -> list of invocation/receipt roots found in that message - index map[string][]indexModel + // agent message CID -> ucan.Container + store map[cid.Cid]ucan.Container + // "///" -> list of agent messages invocation/receipt can be found in + index map[string][]cid.Cid } var _ agent.Store = (*Store)(nil) func New() *Store { return &Store{ - store: map[cid.Cid]carModel{}, - index: map[string][]indexModel{}, + store: map[cid.Cid]ucan.Container{}, + index: map[string][]cid.Cid{}, } } -func (s *Store) GetInvocation(ctx context.Context, task cid.Cid) (invocation.Invocation, error) { +func (s *Store) GetInvocation(ctx context.Context, task cid.Cid) (ucan.Invocation, error) { s.mutex.RLock() defer s.mutex.RUnlock() @@ -54,16 +40,16 @@ func (s *Store) GetInvocation(ctx context.Context, task cid.Cid) (invocation.Inv if !ok || len(records) == 0 { return nil, agent.ErrInvocationNotFound } - archive := s.store[records[0].at] - root := cidlink.Link{Cid: records[0].root} - bs, err := blockstore.NewBlockStore(blockstore.WithBlocks(archive.blocks)) - if err != nil { - return nil, fmt.Errorf("creating blockstore: %w", err) + ct := s.store[records[0]] + for _, inv := range ct.Invocations() { + if inv.Task().Link() == task { + return inv, nil + } } - return invocation.NewInvocationView(root, bs) + return nil, agent.ErrInvocationNotFound } -func (s *Store) GetReceipt(ctx context.Context, task cid.Cid) (receipt.AnyReceipt, error) { +func (s *Store) GetReceipt(ctx context.Context, task cid.Cid) (ucan.Receipt, error) { s.mutex.RLock() defer s.mutex.RUnlock() key := fmt.Sprintf("/%s/receipt/", task) @@ -71,41 +57,46 @@ func (s *Store) GetReceipt(ctx context.Context, task cid.Cid) (receipt.AnyReceip if !ok || len(records) == 0 { return nil, agent.ErrReceiptNotFound } - archive := s.store[records[0].at] - root := cidlink.Link{Cid: records[0].root} - bs, err := blockstore.NewBlockStore(blockstore.WithBlocks(archive.blocks)) - if err != nil { - return nil, fmt.Errorf("creating blockstore: %w", err) + ct := s.store[records[0]] + rcpt, ok := ct.Receipt(task) + if !ok { + return nil, agent.ErrReceiptNotFound } - return receipt.NewAnyReceipt(root, bs) + return rcpt, nil } -func (s *Store) Write(ctx context.Context, message message.AgentMessage, index []agent.IndexEntry, source []byte) error { +func (s *Store) Write(ctx context.Context, message ucan.Container, index []agent.IndexEntry) error { s.mutex.Lock() defer s.mutex.Unlock() - at, err := ipldutil.ToCID(message.Root().Link()) - if err != nil { - return err + c, ok := message.(*container.Container) + if !ok { + c = container.New( + container.WithInvocations(message.Invocations()...), + container.WithReceipts(message.Receipts()...), + container.WithDelegations(message.Delegations()...), + ) + } + + var buf bytes.Buffer + if err := c.MarshalCBOR(&buf); err != nil { + return fmt.Errorf("marshaling agent message to CBOR: %w", err) } - model, err := sourceToCARModel(source) + + at, err := cid.V1Builder{Codec: dagcbor.Code, MhType: multihash.SHA2_256}.Sum(buf.Bytes()) if err != nil { - return fmt.Errorf("converting to CAR model: %w", err) + return fmt.Errorf("hashing agent message: %w", err) } - s.store[at] = model + + s.store[at] = message for _, idx := range index { if idx.Invocation != nil { - root := idx.Invocation.Task - key := fmt.Sprintf("/%s/invocation/", root) - s.index[key] = append(s.index[key], indexModel{root: root, at: at}) + key := fmt.Sprintf("/%s/invocation/", idx.Invocation.Task) + s.index[key] = append(s.index[key], at) } if idx.Receipt != nil { key := fmt.Sprintf("/%s/receipt/", idx.Receipt.Task) - receiptRoot, err := ipldutil.ToCID(idx.Receipt.Receipt.Root().Link()) - if err != nil { - return fmt.Errorf("converting receipt root to CID: %w", err) - } - s.index[key] = append(s.index[key], indexModel{root: receiptRoot, at: at}) + s.index[key] = append(s.index[key], at) } } return nil diff --git a/pkg/store/agent/memory/util.go b/pkg/store/agent/memory/util.go deleted file mode 100644 index f42cfe9..0000000 --- a/pkg/store/agent/memory/util.go +++ /dev/null @@ -1,51 +0,0 @@ -package memory - -import ( - "bytes" - "fmt" - "iter" - - "github.com/ipfs/go-cid" - "github.com/storacha/go-ucanto/core/car" - "github.com/storacha/go-ucanto/core/ipld" - "github.com/storacha/sprue/pkg/internal/ipldutil" -) - -func collect[T any](seq iter.Seq2[T, error]) ([]T, error) { - var items []T - for item, err := range seq { - if err != nil { - return nil, err - } - items = append(items, item) - } - return items, nil -} - -func toCARModel(roots []ipld.Link, blocks iter.Seq2[ipld.Block, error]) (carModel, error) { - rts := make([]cid.Cid, 0, len(roots)) - for _, r := range roots { - c, err := ipldutil.ToCID(r) - if err != nil { - return carModel{}, fmt.Errorf("converting root link to CID: %w", err) - } - rts = append(rts, c) - } - bs, err := collect(blocks) - if err != nil { - return carModel{}, err - } - return carModel{rts, bs}, nil -} - -func sourceToCARModel(source []byte) (carModel, error) { - roots, blocks, err := car.Decode(bytes.NewReader(source)) - if err != nil { - return carModel{}, err - } - model, err := toCARModel(roots, blocks) - if err != nil { - return carModel{}, fmt.Errorf("converting to CAR model: %w", err) - } - return model, nil -} diff --git a/pkg/store/blob_registry/aws/store.go b/pkg/store/blob_registry/aws/store.go index 1baf34a..7eb445d 100644 --- a/pkg/store/blob_registry/aws/store.go +++ b/pkg/store/blob_registry/aws/store.go @@ -9,11 +9,11 @@ import ( "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/dynamodb" "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" + "github.com/fil-forge/libforge/capabilities/blob" + "github.com/fil-forge/libforge/digestutil" + "github.com/fil-forge/ucantone/did" "github.com/ipfs/go-cid" multihash "github.com/multiformats/go-multihash" - captypes "github.com/storacha/go-libstoracha/capabilities/types" - "github.com/storacha/go-libstoracha/digestutil" - "github.com/storacha/go-ucanto/did" "github.com/storacha/sprue/pkg/internal/timeutil" "github.com/storacha/sprue/pkg/store" blobregistry "github.com/storacha/sprue/pkg/store/blob_registry" @@ -127,7 +127,7 @@ func (s *Store) Get(ctx context.Context, space did.DID, digest multihash.Multiha return itemToRecord(out.Item) } -func (s *Store) Register(ctx context.Context, space did.DID, blob captypes.Blob, cause cid.Cid) error { +func (s *Store) Register(ctx context.Context, space did.DID, blob blob.Blob, cause cid.Cid) error { consumers, err := s.collectConsumers(ctx, space) if err != nil { return fmt.Errorf("collecting consumers: %w", err) @@ -350,7 +350,7 @@ func itemToRecord(item map[string]types.AttributeValue) (blobregistry.Record, er return blobregistry.Record{ Space: space, - Blob: captypes.Blob{ + Blob: blob.Blob{ Digest: digest, Size: size, }, diff --git a/pkg/store/blob_registry/blob_registry.go b/pkg/store/blob_registry/blob_registry.go index d547798..46ed9a7 100644 --- a/pkg/store/blob_registry/blob_registry.go +++ b/pkg/store/blob_registry/blob_registry.go @@ -4,11 +4,11 @@ import ( "context" "time" + "github.com/fil-forge/libforge/capabilities/blob" + "github.com/fil-forge/ucantone/did" + "github.com/fil-forge/ucantone/errors" "github.com/ipfs/go-cid" "github.com/multiformats/go-multihash" - "github.com/storacha/go-libstoracha/capabilities/types" - "github.com/storacha/go-ucanto/did" - "github.com/storacha/sprue/pkg/lib/errors" "github.com/storacha/sprue/pkg/store" ) @@ -43,7 +43,7 @@ func WithListCursor(cursor string) ListOption { type Record struct { Space did.DID - Blob types.Blob + Blob blob.Blob Cause cid.Cid InsertedAt time.Time } @@ -53,7 +53,7 @@ type Store interface { Get(ctx context.Context, space did.DID, digest multihash.Multihash) (Record, error) // Adds an item into the registry if it does not already exist. May return // [ErrEntryExists] if the blob is already registered in the space. - Register(ctx context.Context, space did.DID, blob types.Blob, cause cid.Cid) error + Register(ctx context.Context, space did.DID, blob blob.Blob, cause cid.Cid) error // List entries in the registry for a given space. List(ctx context.Context, space did.DID, options ...ListOption) (store.Page[Record], error) // Removes an item from the registry if it exists. diff --git a/pkg/store/blob_registry/blob_registry_test.go b/pkg/store/blob_registry/blob_registry_test.go index 9c78eff..d231706 100644 --- a/pkg/store/blob_registry/blob_registry_test.go +++ b/pkg/store/blob_registry/blob_registry_test.go @@ -5,8 +5,8 @@ import ( "runtime" "testing" + "github.com/fil-forge/libforge/capabilities/blob" "github.com/google/uuid" - captypes "github.com/storacha/go-libstoracha/capabilities/types" "github.com/storacha/sprue/internal/testutil" "github.com/storacha/sprue/pkg/store" blobregistry "github.com/storacha/sprue/pkg/store/blob_registry" @@ -102,9 +102,9 @@ func createAWSStores(t *testing.T) storeBundle { } // randomBlob returns a blob with a random digest and the given size. -func randomBlob(t *testing.T, size uint64) captypes.Blob { +func randomBlob(t *testing.T, size uint64) blob.Blob { t.Helper() - return captypes.Blob{Digest: testutil.RandomMultihash(t), Size: size} + return blob.Blob{Digest: testutil.RandomMultihash(t), Size: size} } func TestBlobRegistryStore(t *testing.T) { diff --git a/pkg/store/blob_registry/memory/store.go b/pkg/store/blob_registry/memory/store.go index d429ee3..4f3ce17 100644 --- a/pkg/store/blob_registry/memory/store.go +++ b/pkg/store/blob_registry/memory/store.go @@ -7,10 +7,10 @@ import ( "sync" "time" + "github.com/fil-forge/libforge/capabilities/blob" + "github.com/fil-forge/ucantone/did" cid "github.com/ipfs/go-cid" multihash "github.com/multiformats/go-multihash" - "github.com/storacha/go-libstoracha/capabilities/types" - "github.com/storacha/go-ucanto/did" "github.com/storacha/sprue/pkg/store" blobregistry "github.com/storacha/sprue/pkg/store/blob_registry" "github.com/storacha/sprue/pkg/store/consumer" @@ -128,7 +128,7 @@ func (s *Store) List(ctx context.Context, space did.DID, options ...blobregistry return store.Page[blobregistry.Record]{Results: results, Cursor: cursor}, nil } -func (s *Store) Register(ctx context.Context, space did.DID, blob types.Blob, cause cid.Cid) error { +func (s *Store) Register(ctx context.Context, space did.DID, blob blob.Blob, cause cid.Cid) error { s.mutex.Lock() defer s.mutex.Unlock() diff --git a/pkg/store/consumer/aws/store.go b/pkg/store/consumer/aws/store.go index 0defb2b..e275f93 100644 --- a/pkg/store/consumer/aws/store.go +++ b/pkg/store/consumer/aws/store.go @@ -10,8 +10,8 @@ import ( "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/dynamodb" "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" + "github.com/fil-forge/ucantone/did" "github.com/ipfs/go-cid" - "github.com/storacha/go-ucanto/did" "github.com/storacha/sprue/pkg/store" "github.com/storacha/sprue/pkg/store/consumer" ) diff --git a/pkg/store/consumer/consumer.go b/pkg/store/consumer/consumer.go index 3c1e405..b351fab 100644 --- a/pkg/store/consumer/consumer.go +++ b/pkg/store/consumer/consumer.go @@ -3,9 +3,9 @@ package consumer import ( "context" + "github.com/fil-forge/ucantone/did" + "github.com/fil-forge/ucantone/errors" "github.com/ipfs/go-cid" - "github.com/storacha/go-ucanto/did" - "github.com/storacha/sprue/pkg/lib/errors" "github.com/storacha/sprue/pkg/store" ) diff --git a/pkg/store/consumer/memory/store.go b/pkg/store/consumer/memory/store.go index ae88d32..055ac8e 100644 --- a/pkg/store/consumer/memory/store.go +++ b/pkg/store/consumer/memory/store.go @@ -8,8 +8,8 @@ import ( "strings" "sync" + "github.com/fil-forge/ucantone/did" cid "github.com/ipfs/go-cid" - "github.com/storacha/go-ucanto/did" "github.com/storacha/sprue/pkg/store" "github.com/storacha/sprue/pkg/store/consumer" ) diff --git a/pkg/store/customer/aws/store.go b/pkg/store/customer/aws/store.go index 6ae70fc..db17904 100644 --- a/pkg/store/customer/aws/store.go +++ b/pkg/store/customer/aws/store.go @@ -10,7 +10,7 @@ import ( "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/dynamodb" "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" - "github.com/storacha/go-ucanto/did" + "github.com/fil-forge/ucantone/did" "github.com/storacha/sprue/pkg/internal/timeutil" "github.com/storacha/sprue/pkg/store" "github.com/storacha/sprue/pkg/store/customer" diff --git a/pkg/store/customer/customer.go b/pkg/store/customer/customer.go index c7d4f7e..e50a1db 100644 --- a/pkg/store/customer/customer.go +++ b/pkg/store/customer/customer.go @@ -4,8 +4,8 @@ import ( "context" "time" - "github.com/storacha/go-ucanto/did" - "github.com/storacha/sprue/pkg/lib/errors" + "github.com/fil-forge/ucantone/did" + "github.com/fil-forge/ucantone/errors" "github.com/storacha/sprue/pkg/store" ) diff --git a/pkg/store/customer/memory/memory.go b/pkg/store/customer/memory/memory.go index a5f9a01..29a619c 100644 --- a/pkg/store/customer/memory/memory.go +++ b/pkg/store/customer/memory/memory.go @@ -7,7 +7,7 @@ import ( "sync" "time" - "github.com/storacha/go-ucanto/did" + "github.com/fil-forge/ucantone/did" "github.com/storacha/sprue/pkg/store" "github.com/storacha/sprue/pkg/store/customer" ) diff --git a/pkg/store/delegation/aws/store.go b/pkg/store/delegation/aws/store.go index 1ee3f6c..29c3e2a 100644 --- a/pkg/store/delegation/aws/store.go +++ b/pkg/store/delegation/aws/store.go @@ -11,9 +11,11 @@ import ( "github.com/aws/aws-sdk-go-v2/service/dynamodb" "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" "github.com/aws/aws-sdk-go-v2/service/s3" + "github.com/fil-forge/ucantone/did" + "github.com/fil-forge/ucantone/ucan" + "github.com/fil-forge/ucantone/ucan/delegation" + "github.com/fil-forge/ucantone/ucan/invocation" "github.com/ipfs/go-cid" - "github.com/storacha/go-ucanto/core/delegation" - "github.com/storacha/go-ucanto/did" "github.com/storacha/sprue/pkg/internal/timeutil" "github.com/storacha/sprue/pkg/store" dlgstore "github.com/storacha/sprue/pkg/store/delegation" @@ -103,16 +105,27 @@ func (s *Store) Initialize(ctx context.Context) error { return nil } -func (s *Store) PutMany(ctx context.Context, delegations []delegation.Delegation, cause cid.Cid) error { +func (s *Store) PutMany(ctx context.Context, tokens []ucan.Token, cause cid.Cid) error { now := time.Now().UTC().Format(timeutil.SimplifiedISO8601) - for _, dlg := range delegations { - link := dlg.Root().Link().String() - - // Archive the delegation to a CAR and store in S3. - body, err := io.ReadAll(dlg.Archive()) - if err != nil { - return fmt.Errorf("archiving delegation %s: %w", link, err) + for _, token := range tokens { + link := token.Link().String() + + var body []byte + var err error + if dlg, ok := token.(ucan.Delegation); ok { + body, err = delegation.Encode(dlg) + if err != nil { + return fmt.Errorf("encoding delegation %s: %w", link, err) + } + } else if inv, ok := token.(ucan.Invocation); ok { + body, err = invocation.Encode(inv) + if err != nil { + return fmt.Errorf("encoding invocation %s: %w", link, err) + } + } else { + return fmt.Errorf("unsupported token type: %T", token) } + if _, err := s.s3.PutObject(ctx, &s3.PutObjectInput{ Bucket: &s.bucketName, Key: aws.String(link), @@ -121,18 +134,26 @@ func (s *Store) PutMany(ctx context.Context, delegations []delegation.Delegation return fmt.Errorf("storing delegation %s in S3: %w", link, err) } + var aud did.DID + // audience may be nil if the token is an invocation + if token.Audience() != nil { + aud = token.Audience().DID() + } else { + aud = token.Subject().DID() + } + // Write the index entry to DynamoDB. item := map[string]types.AttributeValue{ "link": &types.AttributeValueMemberS{Value: link}, - "audience": &types.AttributeValueMemberS{Value: dlg.Audience().DID().String()}, - "issuer": &types.AttributeValueMemberS{Value: dlg.Issuer().DID().String()}, + "audience": &types.AttributeValueMemberS{Value: aud.String()}, + "issuer": &types.AttributeValueMemberS{Value: token.Issuer().DID().String()}, "insertedAt": &types.AttributeValueMemberS{Value: now}, "updatedAt": &types.AttributeValueMemberS{Value: now}, } if cause != cid.Undef { item["cause"] = &types.AttributeValueMemberS{Value: cause.String()} } - if exp := dlg.Expiration(); exp != nil { + if exp := token.Expiration(); exp != nil { item["expiration"] = &types.AttributeValueMemberN{Value: fmt.Sprintf("%d", *exp)} } @@ -146,7 +167,7 @@ func (s *Store) PutMany(ctx context.Context, delegations []delegation.Delegation return nil } -func (s *Store) ListByAudience(ctx context.Context, audience did.DID, options ...dlgstore.ListByAudienceOption) (store.Page[delegation.Delegation], error) { +func (s *Store) ListByAudience(ctx context.Context, audience did.DID, options ...dlgstore.ListByAudienceOption) (store.Page[ucan.Token], error) { cfg := dlgstore.ListByAudienceConfig{} for _, opt := range options { opt(&cfg) @@ -175,18 +196,18 @@ func (s *Store) ListByAudience(ctx context.Context, audience did.DID, options .. out, err := s.dynamo.Query(ctx, input) if err != nil { - return store.Page[delegation.Delegation]{}, fmt.Errorf("querying delegations by audience: %w", err) + return store.Page[ucan.Token]{}, fmt.Errorf("querying delegations by audience: %w", err) } - results := make([]delegation.Delegation, 0, len(out.Items)) + results := make([]ucan.Token, 0, len(out.Items)) for _, item := range out.Items { linkAttr, ok := item["link"].(*types.AttributeValueMemberS) if !ok { - return store.Page[delegation.Delegation]{}, fmt.Errorf("missing or invalid link attribute in DynamoDB item") + return store.Page[ucan.Token]{}, fmt.Errorf("missing or invalid link attribute in DynamoDB item") } - dlg, err := s.fetchDelegation(ctx, linkAttr.Value) + dlg, err := s.fetchToken(ctx, linkAttr.Value) if err != nil { - return store.Page[delegation.Delegation]{}, fmt.Errorf("fetching delegation %s: %w", linkAttr.Value, err) + return store.Page[ucan.Token]{}, fmt.Errorf("fetching delegation %s: %w", linkAttr.Value, err) } results = append(results, dlg) } @@ -198,11 +219,12 @@ func (s *Store) ListByAudience(ctx context.Context, audience did.DID, options .. } } - return store.Page[delegation.Delegation]{Results: results, Cursor: cursor}, nil + return store.Page[ucan.Token]{Results: results, Cursor: cursor}, nil } -// fetchDelegation retrieves and decodes a delegation from S3 by its link CID string. -func (s *Store) fetchDelegation(ctx context.Context, link string) (delegation.Delegation, error) { +// fetchToken retrieves and decodes a delegation/invocation from S3 by its link +// CID string. +func (s *Store) fetchToken(ctx context.Context, link string) (ucan.Token, error) { out, err := s.s3.GetObject(ctx, &s3.GetObjectInput{ Bucket: &s.bucketName, Key: aws.String(link), @@ -212,13 +234,18 @@ func (s *Store) fetchDelegation(ctx context.Context, link string) (delegation.De } defer out.Body.Close() - data, err := io.ReadAll(out.Body) + body, err := io.ReadAll(out.Body) if err != nil { - return nil, fmt.Errorf("reading delegation from S3: %w", err) + return nil, fmt.Errorf("reading delegation body from S3: %w", err) } - dlg, err := delegation.Extract(data) + + inv, err := invocation.Decode(body) if err != nil { - return nil, fmt.Errorf("extracting delegation: %w", err) + dlg, err := delegation.Decode(body) + if err != nil { + return nil, fmt.Errorf("decoding token: %w", err) + } + return dlg, nil } - return dlg, nil + return inv, nil } diff --git a/pkg/store/delegation/delegation.go b/pkg/store/delegation/delegation.go index 499e73e..52293b0 100644 --- a/pkg/store/delegation/delegation.go +++ b/pkg/store/delegation/delegation.go @@ -3,9 +3,9 @@ package delegation import ( "context" + "github.com/fil-forge/ucantone/did" + "github.com/fil-forge/ucantone/ucan" "github.com/ipfs/go-cid" - "github.com/storacha/go-ucanto/core/delegation" - "github.com/storacha/go-ucanto/did" "github.com/storacha/sprue/pkg/store" ) @@ -28,6 +28,6 @@ type Store interface { // Implementations MAY choose to avoid storing delegations as long as they can // reliably retrieve the invocation by CID when they need to return the given // delegations. - PutMany(ctx context.Context, delegations []delegation.Delegation, cause cid.Cid) error - ListByAudience(ctx context.Context, audience did.DID, options ...ListByAudienceOption) (store.Page[delegation.Delegation], error) + PutMany(ctx context.Context, tokens []ucan.Token, cause cid.Cid) error + ListByAudience(ctx context.Context, audience did.DID, options ...ListByAudienceOption) (store.Page[ucan.Token], error) } diff --git a/pkg/store/delegation/delegation_test.go b/pkg/store/delegation/delegation_test.go index 5dd04eb..4b62f22 100644 --- a/pkg/store/delegation/delegation_test.go +++ b/pkg/store/delegation/delegation_test.go @@ -5,9 +5,9 @@ import ( "runtime" "testing" + "github.com/fil-forge/ucantone/ucan" + "github.com/fil-forge/ucantone/ucan/delegation" "github.com/google/uuid" - "github.com/storacha/go-ucanto/core/delegation" - "github.com/storacha/go-ucanto/ucan" "github.com/storacha/sprue/internal/testutil" "github.com/storacha/sprue/pkg/store" dlgstore "github.com/storacha/sprue/pkg/store/delegation" @@ -61,16 +61,13 @@ func createAWSStore(t *testing.T) dlgstore.Store { } // makeDelegation creates a delegation from Alice to the given audience. -// A random nonce is included so each delegation has a unique CID. -func makeDelegation(t *testing.T, audience ucan.Principal) delegation.Delegation { +func makeDelegation(t *testing.T, audience ucan.Principal) ucan.Delegation { t.Helper() dlg, err := delegation.Delegate( testutil.Alice, audience, - []ucan.Capability[ucan.NoCaveats]{ - ucan.NewCapability("test/delegate", testutil.Alice.DID().String(), ucan.NoCaveats{}), - }, - delegation.WithNonce(uuid.NewString()), + testutil.Alice, + "/test/delegate", ) require.NoError(t, err) return dlg @@ -86,12 +83,12 @@ func TestDelegationStore(t *testing.T) { dlg := makeDelegation(t, audience) cause := testutil.RandomCID(t) - require.NoError(t, s.PutMany(t.Context(), []delegation.Delegation{dlg}, cause)) + require.NoError(t, s.PutMany(t.Context(), []ucan.Token{dlg}, cause)) page, err := s.ListByAudience(t.Context(), audience.DID()) require.NoError(t, err) require.Len(t, page.Results, 1) - require.Equal(t, dlg.Root().Link().String(), page.Results[0].Root().Link().String()) + require.Equal(t, dlg.Link().String(), page.Results[0].Link().String()) }) t.Run("ListByAudience returns empty page for unknown audience", func(t *testing.T) { @@ -109,7 +106,7 @@ func TestDelegationStore(t *testing.T) { dlg2 := makeDelegation(t, audience) cause := testutil.RandomCID(t) - require.NoError(t, s.PutMany(t.Context(), []delegation.Delegation{dlg1, dlg2}, cause)) + require.NoError(t, s.PutMany(t.Context(), []ucan.Token{dlg1, dlg2}, cause)) page, err := s.ListByAudience(t.Context(), audience.DID()) require.NoError(t, err) @@ -123,17 +120,17 @@ func TestDelegationStore(t *testing.T) { dlg2 := makeDelegation(t, aud2) cause := testutil.RandomCID(t) - require.NoError(t, s.PutMany(t.Context(), []delegation.Delegation{dlg1, dlg2}, cause)) + require.NoError(t, s.PutMany(t.Context(), []ucan.Token{dlg1, dlg2}, cause)) page1, err := s.ListByAudience(t.Context(), aud1.DID()) require.NoError(t, err) require.Len(t, page1.Results, 1) - require.Equal(t, dlg1.Root().Link().String(), page1.Results[0].Root().Link().String()) + require.Equal(t, dlg1.Link().String(), page1.Results[0].Link().String()) page2, err := s.ListByAudience(t.Context(), aud2.DID()) require.NoError(t, err) require.Len(t, page2.Results, 1) - require.Equal(t, dlg2.Root().Link().String(), page2.Results[0].Root().Link().String()) + require.Equal(t, dlg2.Link().String(), page2.Results[0].Link().String()) }) t.Run("ListByAudience isolates delegations by audience", func(t *testing.T) { @@ -142,9 +139,9 @@ func TestDelegationStore(t *testing.T) { cause := testutil.RandomCID(t) for range 3 { - require.NoError(t, s.PutMany(t.Context(), []delegation.Delegation{makeDelegation(t, aud1)}, cause)) + require.NoError(t, s.PutMany(t.Context(), []ucan.Token{makeDelegation(t, aud1)}, cause)) } - require.NoError(t, s.PutMany(t.Context(), []delegation.Delegation{makeDelegation(t, aud2)}, cause)) + require.NoError(t, s.PutMany(t.Context(), []ucan.Token{makeDelegation(t, aud2)}, cause)) page, err := s.ListByAudience(t.Context(), aud1.DID()) require.NoError(t, err) @@ -156,10 +153,10 @@ func TestDelegationStore(t *testing.T) { cause := testutil.RandomCID(t) for range 5 { - require.NoError(t, s.PutMany(t.Context(), []delegation.Delegation{makeDelegation(t, audience)}, cause)) + require.NoError(t, s.PutMany(t.Context(), []ucan.Token{makeDelegation(t, audience)}, cause)) } - all, err := store.Collect(t.Context(), func(ctx context.Context, opts store.PaginationConfig) (store.Page[delegation.Delegation], error) { + all, err := store.Collect(t.Context(), func(ctx context.Context, opts store.PaginationConfig) (store.Page[ucan.Token], error) { var listOpts []dlgstore.ListByAudienceOption if opts.Cursor != nil { listOpts = append(listOpts, dlgstore.WithListByAudienceCursor(*opts.Cursor)) diff --git a/pkg/store/delegation/memory/store.go b/pkg/store/delegation/memory/store.go index a45b9b9..b760eac 100644 --- a/pkg/store/delegation/memory/store.go +++ b/pkg/store/delegation/memory/store.go @@ -6,27 +6,27 @@ import ( "slices" "sync" + "github.com/fil-forge/ucantone/did" + "github.com/fil-forge/ucantone/ucan" cid "github.com/ipfs/go-cid" - "github.com/storacha/go-ucanto/core/delegation" - "github.com/storacha/go-ucanto/did" "github.com/storacha/sprue/pkg/store" dlgstore "github.com/storacha/sprue/pkg/store/delegation" ) type Store struct { - mutex sync.RWMutex - delegations map[did.DID][]delegation.Delegation + mutex sync.RWMutex + tokens map[did.DID][]ucan.Token } var _ dlgstore.Store = (*Store)(nil) func New() *Store { return &Store{ - delegations: map[did.DID][]delegation.Delegation{}, + tokens: map[did.DID][]ucan.Token{}, } } -func (s *Store) ListByAudience(ctx context.Context, audience did.DID, options ...dlgstore.ListByAudienceOption) (store.Page[delegation.Delegation], error) { +func (s *Store) ListByAudience(ctx context.Context, audience did.DID, options ...dlgstore.ListByAudienceOption) (store.Page[ucan.Token], error) { s.mutex.RLock() defer s.mutex.RUnlock() @@ -35,39 +35,45 @@ func (s *Store) ListByAudience(ctx context.Context, audience did.DID, options .. for _, opt := range options { opt(&cfg) } - delegations := slices.Clone(s.delegations[audience]) + tokens := slices.Clone(s.tokens[audience]) if cfg.Cursor != nil { - for i, d := range delegations { - if d.Root().Link().String() == *cfg.Cursor { - if i+1 < len(delegations) { - delegations = delegations[i+1:] + for i, d := range tokens { + if d.Link().String() == *cfg.Cursor { + if i+1 < len(tokens) { + tokens = tokens[i+1:] } break } } } var cursor *string - if cfg.Limit != nil && len(delegations) > *cfg.Limit { - delegations = delegations[:*cfg.Limit] - last := delegations[len(delegations)-1].Root().Link().String() + if cfg.Limit != nil && len(tokens) > *cfg.Limit { + tokens = tokens[:*cfg.Limit] + last := tokens[len(tokens)-1].Link().String() cursor = &last } - return store.Page[delegation.Delegation]{ + return store.Page[ucan.Token]{ Cursor: cursor, - Results: delegations, + Results: tokens, }, nil } -func (s *Store) PutMany(ctx context.Context, delegations []delegation.Delegation, cause cid.Cid) error { +func (s *Store) PutMany(ctx context.Context, tokens []ucan.Token, cause cid.Cid) error { s.mutex.Lock() defer s.mutex.Unlock() - for _, d := range delegations { - aud := d.Audience().DID() - s.delegations[aud] = append(s.delegations[aud], d) - slices.SortFunc(s.delegations[aud], func(a, b delegation.Delegation) int { - return bytes.Compare(a.Root().Bytes(), b.Root().Bytes()) + for _, d := range tokens { + var aud did.DID + // audience may be nil if the token is an invocation + if d.Audience() != nil { + aud = d.Audience().DID() + } else { + aud = d.Subject().DID() + } + s.tokens[aud] = append(s.tokens[aud], d) + slices.SortFunc(s.tokens[aud], func(a, b ucan.Token) int { + return bytes.Compare(a.Link().Bytes(), b.Link().Bytes()) }) } return nil diff --git a/pkg/store/metrics/aws/store.go b/pkg/store/metrics/aws/store.go index c96241e..8ee462c 100644 --- a/pkg/store/metrics/aws/store.go +++ b/pkg/store/metrics/aws/store.go @@ -8,7 +8,7 @@ import ( "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/dynamodb" "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" - "github.com/storacha/go-ucanto/did" + "github.com/fil-forge/ucantone/did" "github.com/storacha/sprue/pkg/store/metrics" ) diff --git a/pkg/store/metrics/memory/store.go b/pkg/store/metrics/memory/store.go index 2d3c822..b91b0db 100644 --- a/pkg/store/metrics/memory/store.go +++ b/pkg/store/metrics/memory/store.go @@ -4,7 +4,7 @@ import ( "context" "sync" - "github.com/storacha/go-ucanto/did" + "github.com/fil-forge/ucantone/did" "github.com/storacha/sprue/pkg/store/metrics" ) diff --git a/pkg/store/metrics/metrics.go b/pkg/store/metrics/metrics.go index c9e5735..b7adf4b 100644 --- a/pkg/store/metrics/metrics.go +++ b/pkg/store/metrics/metrics.go @@ -3,19 +3,19 @@ package metrics import ( "context" - "github.com/storacha/go-libstoracha/capabilities/space/blob" - "github.com/storacha/go-libstoracha/capabilities/upload" - "github.com/storacha/go-ucanto/did" + "github.com/fil-forge/libforge/capabilities/blob" + "github.com/fil-forge/libforge/capabilities/upload" + "github.com/fil-forge/ucantone/did" ) -const BlobAddTotalMetric = blob.AddAbility + "-total" -const BlobAddSizeTotalMetric = blob.AddAbility + "-size-total" +const BlobAddTotalMetric = blob.AddCommand + "-total" +const BlobAddSizeTotalMetric = blob.AddCommand + "-size-total" -const BlobRemoveTotalMetric = blob.RemoveAbility + "-total" -const BlobRemoveSizeTotalMetric = blob.RemoveAbility + "-size-total" +const BlobRemoveTotalMetric = blob.RemoveCommand + "-total" +const BlobRemoveSizeTotalMetric = blob.RemoveCommand + "-size-total" -const UploadAddTotalMetric = upload.AddAbility + "-total" -const UploadRemoveTotalMetric = upload.RemoveAbility + "-total" +const UploadAddTotalMetric = upload.AddCommand + "-total" +const UploadRemoveTotalMetric = upload.RemoveCommand + "-total" type Store interface { // Get all metrics from storage. diff --git a/pkg/store/replica/aws/store.go b/pkg/store/replica/aws/store.go index 57325b4..24dd8d7 100644 --- a/pkg/store/replica/aws/store.go +++ b/pkg/store/replica/aws/store.go @@ -9,10 +9,10 @@ import ( "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/dynamodb" "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" + "github.com/fil-forge/libforge/digestutil" + "github.com/fil-forge/ucantone/did" "github.com/ipfs/go-cid" "github.com/multiformats/go-multihash" - "github.com/storacha/go-libstoracha/digestutil" - "github.com/storacha/go-ucanto/did" "github.com/storacha/sprue/pkg/internal/timeutil" "github.com/storacha/sprue/pkg/store/replica" ) diff --git a/pkg/store/replica/memory/store.go b/pkg/store/replica/memory/store.go index ba16964..a4af2ba 100644 --- a/pkg/store/replica/memory/store.go +++ b/pkg/store/replica/memory/store.go @@ -8,10 +8,10 @@ import ( "sync" "time" + "github.com/fil-forge/libforge/bytemap" + "github.com/fil-forge/ucantone/did" cid "github.com/ipfs/go-cid" "github.com/multiformats/go-multihash" - "github.com/storacha/go-libstoracha/bytemap" - "github.com/storacha/go-ucanto/did" "github.com/storacha/sprue/pkg/store/replica" ) diff --git a/pkg/store/replica/replica.go b/pkg/store/replica/replica.go index 6e05842..a808b70 100644 --- a/pkg/store/replica/replica.go +++ b/pkg/store/replica/replica.go @@ -4,10 +4,10 @@ import ( "context" "time" + "github.com/fil-forge/ucantone/did" + "github.com/fil-forge/ucantone/errors" "github.com/ipfs/go-cid" "github.com/multiformats/go-multihash" - "github.com/storacha/go-ucanto/did" - "github.com/storacha/sprue/pkg/lib/errors" ) const ( diff --git a/pkg/store/revocation/aws/store.go b/pkg/store/revocation/aws/store.go index 2ece8b6..9990b27 100644 --- a/pkg/store/revocation/aws/store.go +++ b/pkg/store/revocation/aws/store.go @@ -8,8 +8,8 @@ import ( "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/dynamodb" "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" + "github.com/fil-forge/ucantone/did" "github.com/ipfs/go-cid" - "github.com/storacha/go-ucanto/did" "github.com/storacha/sprue/pkg/internal/timeutil" "github.com/storacha/sprue/pkg/store/revocation" ) diff --git a/pkg/store/revocation/memory/store.go b/pkg/store/revocation/memory/store.go index 3c51b31..63ebcea 100644 --- a/pkg/store/revocation/memory/store.go +++ b/pkg/store/revocation/memory/store.go @@ -5,8 +5,8 @@ import ( "maps" "sync" + "github.com/fil-forge/ucantone/did" "github.com/ipfs/go-cid" - "github.com/storacha/go-ucanto/did" "github.com/storacha/sprue/pkg/store/revocation" ) diff --git a/pkg/store/revocation/revocation.go b/pkg/store/revocation/revocation.go index 0112994..c59e2f0 100644 --- a/pkg/store/revocation/revocation.go +++ b/pkg/store/revocation/revocation.go @@ -3,8 +3,8 @@ package revocation import ( "context" + "github.com/fil-forge/ucantone/did" "github.com/ipfs/go-cid" - "github.com/storacha/go-ucanto/did" ) type Store interface { diff --git a/pkg/store/space_diff/aws/store.go b/pkg/store/space_diff/aws/store.go index 5db867b..abb044b 100644 --- a/pkg/store/space_diff/aws/store.go +++ b/pkg/store/space_diff/aws/store.go @@ -9,8 +9,8 @@ import ( "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/dynamodb" "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" + "github.com/fil-forge/ucantone/did" "github.com/ipfs/go-cid" - "github.com/storacha/go-ucanto/did" "github.com/storacha/sprue/pkg/internal/timeutil" "github.com/storacha/sprue/pkg/store" spacediff "github.com/storacha/sprue/pkg/store/space_diff" diff --git a/pkg/store/space_diff/memory/store.go b/pkg/store/space_diff/memory/store.go index 9d0f3f7..c60b360 100644 --- a/pkg/store/space_diff/memory/store.go +++ b/pkg/store/space_diff/memory/store.go @@ -7,8 +7,8 @@ import ( "sync" "time" + "github.com/fil-forge/ucantone/did" cid "github.com/ipfs/go-cid" - "github.com/storacha/go-ucanto/did" "github.com/storacha/sprue/pkg/internal/timeutil" "github.com/storacha/sprue/pkg/store" spacediff "github.com/storacha/sprue/pkg/store/space_diff" diff --git a/pkg/store/space_diff/space_diff.go b/pkg/store/space_diff/space_diff.go index c4fc371..d2a71b3 100644 --- a/pkg/store/space_diff/space_diff.go +++ b/pkg/store/space_diff/space_diff.go @@ -4,8 +4,8 @@ import ( "context" "time" + "github.com/fil-forge/ucantone/did" "github.com/ipfs/go-cid" - "github.com/storacha/go-ucanto/did" "github.com/storacha/sprue/pkg/store" ) diff --git a/pkg/store/storage_provider/aws/store.go b/pkg/store/storage_provider/aws/store.go index 14a4f8d..39e2987 100644 --- a/pkg/store/storage_provider/aws/store.go +++ b/pkg/store/storage_provider/aws/store.go @@ -11,8 +11,7 @@ import ( "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/dynamodb" "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" - "github.com/storacha/go-ucanto/core/delegation" - "github.com/storacha/go-ucanto/did" + "github.com/fil-forge/ucantone/did" "github.com/storacha/sprue/pkg/internal/timeutil" "github.com/storacha/sprue/pkg/store" storageprovider "github.com/storacha/sprue/pkg/store/storage_provider" @@ -65,16 +64,11 @@ func (s *Store) Initialize(ctx context.Context) error { return nil } -func (s *Store) Put(ctx context.Context, endpoint url.URL, proof delegation.Delegation, weight int, replicationWeight *int) error { - proofStr, err := delegation.Format(proof) - if err != nil { - return fmt.Errorf("formatting proof: %w", err) - } - +func (s *Store) Put(ctx context.Context, id did.DID, endpoint url.URL, weight int, replicationWeight *int) error { now := time.Now().UTC().Format(timeutil.SimplifiedISO8601) input := dynamodb.UpdateItemInput{ TableName: aws.String(s.tableName), - Key: map[string]types.AttributeValue{"provider": &types.AttributeValueMemberS{Value: proof.Issuer().DID().String()}}, + Key: map[string]types.AttributeValue{"provider": &types.AttributeValueMemberS{Value: id.String()}}, UpdateExpression: aws.String( "SET #endpoint = :endpoint, #proof = :proof, #weight = :weight, #replicationWeight = :replicationWeight, #insertedAt = if_not_exists(#insertedAt, :now), #updatedAt = :now", ), @@ -87,7 +81,6 @@ func (s *Store) Put(ctx context.Context, endpoint url.URL, proof delegation.Dele }, ExpressionAttributeValues: map[string]types.AttributeValue{ ":endpoint": &types.AttributeValueMemberS{Value: endpoint.String()}, - ":proof": &types.AttributeValueMemberS{Value: proofStr}, ":weight": &types.AttributeValueMemberN{Value: strconv.Itoa(weight)}, ":now": &types.AttributeValueMemberS{Value: now}, }, @@ -97,7 +90,7 @@ func (s *Store) Put(ctx context.Context, endpoint url.URL, proof delegation.Dele input.ExpressionAttributeValues[":replicationWeight"] = &types.AttributeValueMemberN{Value: strconv.Itoa(*replicationWeight)} } - _, err = s.dynamo.UpdateItem(ctx, &input) + _, err := s.dynamo.UpdateItem(ctx, &input) if err != nil { return fmt.Errorf("storing storage provider: %w", err) } @@ -199,15 +192,6 @@ func itemToRecord(item map[string]types.AttributeValue) (storageprovider.Record, return storageprovider.Record{}, fmt.Errorf("parsing endpoint URL: %w", err) } - proofAttr, ok := item["proof"].(*types.AttributeValueMemberS) - if !ok { - return storageprovider.Record{}, fmt.Errorf("missing or invalid proof attribute") - } - proof, err := delegation.Parse(proofAttr.Value) - if err != nil { - return storageprovider.Record{}, fmt.Errorf("parsing proof: %w", err) - } - weightAttr, ok := item["weight"].(*types.AttributeValueMemberN) if !ok { return storageprovider.Record{}, fmt.Errorf("missing or invalid weight attribute") @@ -233,7 +217,6 @@ func itemToRecord(item map[string]types.AttributeValue) (storageprovider.Record, rec := storageprovider.Record{ Provider: providerDID, Endpoint: *endpointURL, - Proof: proof, Weight: weight, ReplicationWeight: replicationWeight, } diff --git a/pkg/store/storage_provider/memory/store.go b/pkg/store/storage_provider/memory/store.go index cb66c2b..e67a0d3 100644 --- a/pkg/store/storage_provider/memory/store.go +++ b/pkg/store/storage_provider/memory/store.go @@ -9,8 +9,7 @@ import ( "sync" "time" - "github.com/storacha/go-ucanto/core/delegation" - "github.com/storacha/go-ucanto/did" + "github.com/fil-forge/ucantone/did" "github.com/storacha/sprue/pkg/store" storageprovider "github.com/storacha/sprue/pkg/store/storage_provider" ) @@ -85,22 +84,20 @@ func (s *Store) List(ctx context.Context, options ...storageprovider.ListOption) return store.Page[storageprovider.Record]{Results: records, Cursor: cursor}, nil } -func (s *Store) Put(ctx context.Context, endpoint url.URL, proof delegation.Delegation, weight int, replicationWeight *int) error { +func (s *Store) Put(ctx context.Context, id did.DID, endpoint url.URL, weight int, replicationWeight *int) error { s.mutex.Lock() defer s.mutex.Unlock() - if sp, ok := s.providers[proof.Issuer().DID()]; ok { + if sp, ok := s.providers[id]; ok { sp.Endpoint = endpoint - sp.Proof = proof sp.Weight = weight sp.ReplicationWeight = replicationWeight sp.UpdatedAt = time.Now() - s.providers[proof.Issuer().DID()] = sp + s.providers[id] = sp return nil } - s.providers[proof.Issuer().DID()] = storageprovider.Record{ - Provider: proof.Issuer().DID(), + s.providers[id] = storageprovider.Record{ + Provider: id, Endpoint: endpoint, - Proof: proof, Weight: weight, ReplicationWeight: replicationWeight, InsertedAt: time.Now(), diff --git a/pkg/store/storage_provider/storage_provider.go b/pkg/store/storage_provider/storage_provider.go index 1475ffa..2b7c78f 100644 --- a/pkg/store/storage_provider/storage_provider.go +++ b/pkg/store/storage_provider/storage_provider.go @@ -5,9 +5,8 @@ import ( "net/url" "time" - "github.com/storacha/go-ucanto/core/delegation" - "github.com/storacha/go-ucanto/did" - "github.com/storacha/sprue/pkg/lib/errors" + "github.com/fil-forge/ucantone/did" + "github.com/fil-forge/ucantone/errors" "github.com/storacha/sprue/pkg/store" ) @@ -43,8 +42,6 @@ type Record struct { Provider did.DID // Public URL that accepts UCAN invocations. Endpoint url.URL - // Proof the upload service can invoke blob/allocate and blob/accept. - Proof delegation.Delegation // Weight determines chance of selection for uploads relative to other // providers. Weight int @@ -58,7 +55,7 @@ type Record struct { } type Store interface { - Put(ctx context.Context, endpoint url.URL, proof delegation.Delegation, weight int, replicationWeight *int) error + Put(ctx context.Context, providerID did.DID, endpoint url.URL, weight int, replicationWeight *int) error // Get a storage provider record by provider DID. May return // [ErrStorageProviderNotFound]. Get(ctx context.Context, providerID did.DID) (Record, error) diff --git a/pkg/store/storage_provider/storage_provider_test.go b/pkg/store/storage_provider/storage_provider_test.go index 17389fb..8ce148a 100644 --- a/pkg/store/storage_provider/storage_provider_test.go +++ b/pkg/store/storage_provider/storage_provider_test.go @@ -7,8 +7,6 @@ import ( "testing" "github.com/google/uuid" - "github.com/storacha/go-ucanto/core/delegation" - "github.com/storacha/go-ucanto/ucan" "github.com/storacha/sprue/internal/testutil" "github.com/storacha/sprue/pkg/store" storageprovider "github.com/storacha/sprue/pkg/store/storage_provider" @@ -64,22 +62,6 @@ func randomEndpoint(t *testing.T) url.URL { return *u } -// makeProof creates a delegation from Alice to a random audience. -func makeProof(t *testing.T, issuer ucan.Signer) delegation.Delegation { - t.Helper() - audience := testutil.RandomSigner(t) - dlg, err := delegation.Delegate( - issuer, - audience, - []ucan.Capability[ucan.NoCaveats]{ - ucan.NewCapability("blob/allocate", testutil.Alice.DID().String(), ucan.NoCaveats{}), - }, - delegation.WithNonce(uuid.NewString()), - ) - require.NoError(t, err) - return dlg -} - func TestStorageProviderStore(t *testing.T) { for _, k := range storeKinds { t.Run(string(k), func(t *testing.T) { @@ -88,17 +70,15 @@ func TestStorageProviderStore(t *testing.T) { t.Run("puts and gets a provider", func(t *testing.T) { provider := testutil.Alice endpoint := randomEndpoint(t) - proof := makeProof(t, provider) weight := 10 replWeight := 5 - require.NoError(t, s.Put(t.Context(), endpoint, proof, weight, &replWeight)) + require.NoError(t, s.Put(t.Context(), provider.DID(), endpoint, weight, &replWeight)) rec, err := s.Get(t.Context(), provider.DID()) require.NoError(t, err) require.Equal(t, provider.DID(), rec.Provider) require.Equal(t, endpoint, rec.Endpoint) - require.Equal(t, proof.Root().Link(), rec.Proof.Root().Link()) require.Equal(t, weight, rec.Weight) require.Equal(t, replWeight, *rec.ReplicationWeight) require.False(t, rec.InsertedAt.IsZero()) @@ -108,20 +88,17 @@ func TestStorageProviderStore(t *testing.T) { provider := testutil.Alice endpoint1 := randomEndpoint(t) endpoint2 := randomEndpoint(t) - proof1 := makeProof(t, provider) - proof2 := makeProof(t, provider) weight1 := 10 weight2 := 20 replWeight1 := 5 replWeight2 := 15 - require.NoError(t, s.Put(t.Context(), endpoint1, proof1, weight1, &replWeight1)) - require.NoError(t, s.Put(t.Context(), endpoint2, proof2, weight2, &replWeight2)) + require.NoError(t, s.Put(t.Context(), provider.DID(), endpoint1, weight1, &replWeight1)) + require.NoError(t, s.Put(t.Context(), provider.DID(), endpoint2, weight2, &replWeight2)) rec, err := s.Get(t.Context(), provider.DID()) require.NoError(t, err) require.Equal(t, endpoint2, rec.Endpoint) - require.Equal(t, proof2.Root().Link(), rec.Proof.Root().Link()) require.Equal(t, weight2, rec.Weight) require.Equal(t, replWeight2, *rec.ReplicationWeight) }) @@ -136,11 +113,10 @@ func TestStorageProviderStore(t *testing.T) { t.Run("deletes a provider", func(t *testing.T) { provider := testutil.Alice endpoint := randomEndpoint(t) - proof := makeProof(t, provider) weight := 10 replWeight := 5 - require.NoError(t, s.Put(t.Context(), endpoint, proof, weight, &replWeight)) + require.NoError(t, s.Put(t.Context(), provider.DID(), endpoint, weight, &replWeight)) require.NoError(t, s.Delete(t.Context(), provider.DID())) _, err := s.Get(t.Context(), provider.DID()) @@ -158,13 +134,11 @@ func TestStorageProviderStore(t *testing.T) { provider1 := testutil.Alice provider2 := testutil.Bob endpoint := randomEndpoint(t) - proof1 := makeProof(t, provider1) - proof2 := makeProof(t, provider2) weight := 10 replWeight := 5 - require.NoError(t, s.Put(t.Context(), endpoint, proof1, weight, &replWeight)) - require.NoError(t, s.Put(t.Context(), endpoint, proof2, weight, &replWeight)) + require.NoError(t, s.Put(t.Context(), provider1.DID(), endpoint, weight, &replWeight)) + require.NoError(t, s.Put(t.Context(), provider2.DID(), endpoint, weight, &replWeight)) all, err := store.Collect(t.Context(), func(ctx context.Context, opts store.PaginationConfig) (store.Page[storageprovider.Record], error) { var listOpts []storageprovider.ListOption @@ -187,9 +161,9 @@ func TestStorageProviderStore(t *testing.T) { weight := 10 replWeight := 5 for range 5 { + provider := testutil.RandomDID(t) endpoint := randomEndpoint(t) - proof := makeProof(t, testutil.RandomSigner(t)) - require.NoError(t, s.Put(t.Context(), endpoint, proof, weight, &replWeight)) + require.NoError(t, s.Put(t.Context(), provider, endpoint, weight, &replWeight)) } all, err := store.Collect(t.Context(), func(ctx context.Context, opts store.PaginationConfig) (store.Page[storageprovider.Record], error) { @@ -206,11 +180,10 @@ func TestStorageProviderStore(t *testing.T) { t.Run("deleted provider does not appear in List", func(t *testing.T) { provider := testutil.Alice endpoint := randomEndpoint(t) - proof := makeProof(t, provider) weight := 10 replWeight := 5 - require.NoError(t, s.Put(t.Context(), endpoint, proof, weight, &replWeight)) + require.NoError(t, s.Put(t.Context(), provider.DID(), endpoint, weight, &replWeight)) require.NoError(t, s.Delete(t.Context(), provider.DID())) all, err := store.Collect(t.Context(), func(ctx context.Context, opts store.PaginationConfig) (store.Page[storageprovider.Record], error) { @@ -223,7 +196,7 @@ func TestStorageProviderStore(t *testing.T) { require.NoError(t, err) for _, r := range all { - require.NotEqual(t, provider, r.Provider) + require.NotEqual(t, provider.DID(), r.Provider) } }) }) diff --git a/pkg/store/subscription/aws/store.go b/pkg/store/subscription/aws/store.go index 5aedde3..6b9c392 100644 --- a/pkg/store/subscription/aws/store.go +++ b/pkg/store/subscription/aws/store.go @@ -9,8 +9,8 @@ import ( "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/dynamodb" "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" + "github.com/fil-forge/ucantone/did" "github.com/ipfs/go-cid" - "github.com/storacha/go-ucanto/did" "github.com/storacha/sprue/pkg/store" "github.com/storacha/sprue/pkg/store/subscription" ) diff --git a/pkg/store/subscription/memory/store.go b/pkg/store/subscription/memory/store.go index 9570604..7c7358d 100644 --- a/pkg/store/subscription/memory/store.go +++ b/pkg/store/subscription/memory/store.go @@ -8,8 +8,8 @@ import ( "strings" "sync" + "github.com/fil-forge/ucantone/did" cid "github.com/ipfs/go-cid" - "github.com/storacha/go-ucanto/did" "github.com/storacha/sprue/pkg/store" "github.com/storacha/sprue/pkg/store/subscription" ) diff --git a/pkg/store/subscription/subscription.go b/pkg/store/subscription/subscription.go index eda9b17..1795de7 100644 --- a/pkg/store/subscription/subscription.go +++ b/pkg/store/subscription/subscription.go @@ -4,9 +4,9 @@ import ( "context" "time" + "github.com/fil-forge/ucantone/did" + "github.com/fil-forge/ucantone/errors" "github.com/ipfs/go-cid" - "github.com/storacha/go-ucanto/did" - "github.com/storacha/sprue/pkg/lib/errors" "github.com/storacha/sprue/pkg/store" ) diff --git a/pkg/store/subscription/subscription_test.go b/pkg/store/subscription/subscription_test.go index 6b4fe36..87dde1d 100644 --- a/pkg/store/subscription/subscription_test.go +++ b/pkg/store/subscription/subscription_test.go @@ -5,8 +5,8 @@ import ( "runtime" "testing" + "github.com/fil-forge/ucantone/did" "github.com/google/uuid" - "github.com/storacha/go-ucanto/did" "github.com/storacha/sprue/internal/testutil" "github.com/storacha/sprue/pkg/store" "github.com/storacha/sprue/pkg/store/subscription" diff --git a/pkg/store/upload/aws/store.go b/pkg/store/upload/aws/store.go index be4e19c..8b63d75 100644 --- a/pkg/store/upload/aws/store.go +++ b/pkg/store/upload/aws/store.go @@ -12,13 +12,13 @@ import ( "github.com/aws/aws-sdk-go-v2/service/dynamodb" "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" "github.com/aws/aws-sdk-go-v2/service/s3" + "github.com/fil-forge/ucantone/did" "github.com/ipfs/go-cid" "github.com/ipld/go-ipld-prime/codec/dagcbor" "github.com/ipld/go-ipld-prime/fluent" cidlink "github.com/ipld/go-ipld-prime/linking/cid" basicnode "github.com/ipld/go-ipld-prime/node/basic" "github.com/multiformats/go-multihash" - "github.com/storacha/go-ucanto/did" "github.com/storacha/sprue/pkg/internal/timeutil" "github.com/storacha/sprue/pkg/store" "github.com/storacha/sprue/pkg/store/upload" @@ -336,7 +336,7 @@ func (d *Store) Remove(ctx context.Context, space did.DID, root cid.Cid) error { return nil } -func (d *Store) Upsert(ctx context.Context, space did.DID, root cid.Cid, shards []cid.Cid, cause cid.Cid) error { +func (d *Store) Upsert(ctx context.Context, space did.DID, root cid.Cid, index *cid.Cid, shards []cid.Cid, cause cid.Cid) error { // Fetch the current item to get existing shards before merging. current, err := d.dynamo.GetItem(ctx, &dynamodb.GetItemInput{ TableName: aws.String(d.tableName), @@ -370,6 +370,7 @@ func (d *Store) Upsert(ctx context.Context, space did.DID, root cid.Cid, shards "#insertedAt": "insertedAt", "#updatedAt": "updatedAt", "#cause": "cause", + "#index": "index", "#shardsRef": "shardsRef", "#shards": "shards", } @@ -377,6 +378,9 @@ func (d *Store) Upsert(ctx context.Context, space did.DID, root cid.Cid, shards ":now": &types.AttributeValueMemberS{Value: now}, ":cause": &types.AttributeValueMemberS{Value: cause.String()}, } + if index != nil { + exprAttrValues[":index"] = &types.AttributeValueMemberS{Value: index.String()} + } var updateExpr string var newShardsRef string @@ -394,16 +398,28 @@ func (d *Store) Upsert(ctx context.Context, space did.DID, root cid.Cid, shards return fmt.Errorf("storing shards in S3: %w", err) } exprAttrValues[":shardsRef"] = &types.AttributeValueMemberS{Value: newShardsRef} - updateExpr = "SET #insertedAt = if_not_exists(#insertedAt, :now), #updatedAt = :now, #cause = :cause, #shardsRef = :shardsRef REMOVE #shards" + if index != nil { + updateExpr = "SET #insertedAt = if_not_exists(#insertedAt, :now), #updatedAt = :now, #cause = :cause, #shardsRef = :shardsRef, #index = :index REMOVE #shards" + } else { + updateExpr = "SET #insertedAt = if_not_exists(#insertedAt, :now), #updatedAt = :now, #cause = :cause, #shardsRef = :shardsRef REMOVE #shards, #index" + } } else if len(merged) > 0 { shardStrs := make([]string, len(merged)) for i, s := range merged { shardStrs[i] = s.String() } exprAttrValues[":shards"] = &types.AttributeValueMemberSS{Value: shardStrs} - updateExpr = "SET #insertedAt = if_not_exists(#insertedAt, :now), #updatedAt = :now, #cause = :cause, #shards = :shards REMOVE #shardsRef" + if index != nil { + updateExpr = "SET #insertedAt = if_not_exists(#insertedAt, :now), #updatedAt = :now, #cause = :cause, #shards = :shards, #index = :index REMOVE #shardsRef" + } else { + updateExpr = "SET #insertedAt = if_not_exists(#insertedAt, :now), #updatedAt = :now, #cause = :cause, #shards = :shards REMOVE #shardsRef, #index" + } } else { - updateExpr = "SET #insertedAt = if_not_exists(#insertedAt, :now), #updatedAt = :now, #cause = :cause REMOVE #shards, #shardsRef" + if index != nil { + updateExpr = "SET #insertedAt = if_not_exists(#insertedAt, :now), #updatedAt = :now, #cause = :cause, #index = :index REMOVE #shards, #shardsRef" + } else { + updateExpr = "SET #insertedAt = if_not_exists(#insertedAt, :now), #updatedAt = :now, #cause = :cause REMOVE #shards, #shardsRef, #index" + } } _, err = d.dynamo.UpdateItem(ctx, &dynamodb.UpdateItemInput{ @@ -497,6 +513,19 @@ func itemToRecord(item map[string]types.AttributeValue) (upload.UploadRecord, er return upload.UploadRecord{}, fmt.Errorf("parsing root CID: %w", err) } + var index *cid.Cid + if _, ok := item["index"]; ok { + indexAttr, ok := item["index"].(*types.AttributeValueMemberS) + if !ok { + return upload.UploadRecord{}, fmt.Errorf("missing or invalid index attribute") + } + c, err := cid.Parse(indexAttr.Value) + if err != nil { + return upload.UploadRecord{}, fmt.Errorf("parsing index CID: %w", err) + } + index = &c + } + causeAttr, ok := item["cause"].(*types.AttributeValueMemberS) if !ok { return upload.UploadRecord{}, fmt.Errorf("missing or invalid cause attribute") @@ -522,6 +551,7 @@ func itemToRecord(item map[string]types.AttributeValue) (upload.UploadRecord, er return upload.UploadRecord{ Space: space, Root: root, + Index: index, Cause: cause, InsertedAt: insertedAt, UpdatedAt: updatedAt, diff --git a/pkg/store/upload/memory/store.go b/pkg/store/upload/memory/store.go index 2722881..b97f570 100644 --- a/pkg/store/upload/memory/store.go +++ b/pkg/store/upload/memory/store.go @@ -7,8 +7,8 @@ import ( "sync" "time" + "github.com/fil-forge/ucantone/did" "github.com/ipfs/go-cid" - "github.com/storacha/go-ucanto/did" "github.com/storacha/sprue/pkg/store" "github.com/storacha/sprue/pkg/store/upload" ) @@ -146,7 +146,7 @@ func (m *Store) Remove(ctx context.Context, space did.DID, root cid.Cid) error { return nil } -func (m *Store) Upsert(ctx context.Context, space did.DID, root cid.Cid, shards []cid.Cid, cause cid.Cid) error { +func (m *Store) Upsert(ctx context.Context, space did.DID, root cid.Cid, index *cid.Cid, shards []cid.Cid, cause cid.Cid) error { m.mutex.Lock() defer m.mutex.Unlock() @@ -162,6 +162,7 @@ func (m *Store) Upsert(ctx context.Context, space did.DID, root cid.Cid, shards uploads = append(uploads, upload.UploadRecord{ Space: space, Root: root, + Index: index, Cause: cause, InsertedAt: time.Now(), }) @@ -169,6 +170,7 @@ func (m *Store) Upsert(ctx context.Context, space did.DID, root cid.Cid, shards } else { uploads[idx].UpdatedAt = time.Now() uploads[idx].Cause = cause + uploads[idx].Index = index } shardsByUpload, ok := m.shards[space] if !ok { diff --git a/pkg/store/upload/upload.go b/pkg/store/upload/upload.go index fea3884..0657cc7 100644 --- a/pkg/store/upload/upload.go +++ b/pkg/store/upload/upload.go @@ -4,9 +4,9 @@ import ( "context" "time" + "github.com/fil-forge/ucantone/did" + "github.com/fil-forge/ucantone/errors" "github.com/ipfs/go-cid" - "github.com/storacha/go-ucanto/did" - "github.com/storacha/sprue/pkg/lib/errors" "github.com/storacha/sprue/pkg/store" ) @@ -49,6 +49,7 @@ func WithListShardsCursor(cursor string) ListShardsOption { type UploadRecord struct { Space did.DID Root cid.Cid + Index *cid.Cid Cause cid.Cid InsertedAt time.Time UpdatedAt time.Time @@ -70,5 +71,5 @@ type Store interface { Remove(ctx context.Context, space did.DID, root cid.Cid) error // Inserts an item in the table if it does not already exist or updates an // existing item if it does exist. - Upsert(ctx context.Context, space did.DID, root cid.Cid, shards []cid.Cid, cause cid.Cid) error + Upsert(ctx context.Context, space did.DID, root cid.Cid, index *cid.Cid, shards []cid.Cid, cause cid.Cid) error } diff --git a/pkg/store/upload/upload_test.go b/pkg/store/upload/upload_test.go index eb35018..d1d0394 100644 --- a/pkg/store/upload/upload_test.go +++ b/pkg/store/upload/upload_test.go @@ -5,9 +5,9 @@ import ( "runtime" "testing" + "github.com/fil-forge/ucantone/did" "github.com/google/uuid" "github.com/ipfs/go-cid" - "github.com/storacha/go-ucanto/did" "github.com/storacha/sprue/internal/testutil" "github.com/storacha/sprue/pkg/store" "github.com/storacha/sprue/pkg/store/upload" @@ -82,10 +82,11 @@ func TestUploadStore(t *testing.T) { t.Run("adds an upload", func(t *testing.T) { space := testutil.RandomDID(t) root := testutil.RandomCID(t) + index := testutil.RandomCID(t) shards := []cid.Cid{testutil.RandomCID(t), testutil.RandomCID(t)} cause := testutil.RandomCID(t) - err := store.Upsert(t.Context(), space, root, shards, cause) + err := store.Upsert(t.Context(), space, root, &index, shards, cause) require.NoError(t, err) exists, err := store.Exists(t.Context(), space, root) @@ -102,10 +103,11 @@ func TestUploadStore(t *testing.T) { t.Run("lists uploads", func(t *testing.T) { space := testutil.RandomDID(t) roots := []cid.Cid{testutil.RandomCID(t), testutil.RandomCID(t), testutil.RandomCID(t)} + indexes := []cid.Cid{testutil.RandomCID(t), testutil.RandomCID(t), testutil.RandomCID(t)} cause := testutil.RandomCID(t) - for _, root := range roots { - err := store.Upsert(t.Context(), space, root, nil, cause) + for i, root := range roots { + err := store.Upsert(t.Context(), space, root, &indexes[i], nil, cause) require.NoError(t, err) } @@ -130,6 +132,7 @@ func TestUploadStore(t *testing.T) { t.Run("updates an upload", func(t *testing.T) { space := testutil.RandomDID(t) root := testutil.RandomCID(t) + index := testutil.RandomCID(t) cause := testutil.RandomCID(t) initialShards := make([]cid.Cid, 3) @@ -137,7 +140,7 @@ func TestUploadStore(t *testing.T) { initialShards[i] = testutil.RandomCID(t) } - err := store.Upsert(t.Context(), space, root, initialShards, cause) + err := store.Upsert(t.Context(), space, root, nil, initialShards, cause) require.NoError(t, err) // build a second batch of shards that includes one duplicate from the @@ -150,7 +153,7 @@ func TestUploadStore(t *testing.T) { } newCause := testutil.RandomCID(t) - err = store.Upsert(t.Context(), space, root, additionalShards, newCause) + err = store.Upsert(t.Context(), space, root, &index, additionalShards, newCause) require.NoError(t, err) // cause should be updated @@ -168,6 +171,7 @@ func TestUploadStore(t *testing.T) { t.Run("inspects an upload", func(t *testing.T) { root := testutil.RandomCID(t) + index := testutil.RandomCID(t) cause := testutil.RandomCID(t) // inspecting a root not in any space returns empty spaces @@ -178,8 +182,8 @@ func TestUploadStore(t *testing.T) { // upsert the root into two different spaces space1 := testutil.RandomDID(t) space2 := testutil.RandomDID(t) - require.NoError(t, store.Upsert(t.Context(), space1, root, nil, cause)) - require.NoError(t, store.Upsert(t.Context(), space2, root, nil, cause)) + require.NoError(t, store.Upsert(t.Context(), space1, root, &index, nil, cause)) + require.NoError(t, store.Upsert(t.Context(), space2, root, &index, nil, cause)) record, err = store.Inspect(t.Context(), root) require.NoError(t, err) @@ -199,6 +203,7 @@ func TestUploadStore(t *testing.T) { t.Run(tc.name, func(t *testing.T) { space := testutil.RandomDID(t) root := testutil.RandomCID(t) + index := testutil.RandomCID(t) cause := testutil.RandomCID(t) // removing a non-existent upload returns an error @@ -210,7 +215,7 @@ func TestUploadStore(t *testing.T) { shards[i] = testutil.RandomCID(t) } - err = store.Upsert(t.Context(), space, root, shards, cause) + err = store.Upsert(t.Context(), space, root, &index, shards, cause) require.NoError(t, err) err = store.Remove(t.Context(), space, root) @@ -243,6 +248,7 @@ func TestUploadStore(t *testing.T) { t.Run(tc.name, func(t *testing.T) { space := testutil.RandomDID(t) root := testutil.RandomCID(t) + index := testutil.RandomCID(t) cause := testutil.RandomCID(t) shards := make([]cid.Cid, tc.shardCount) @@ -250,7 +256,7 @@ func TestUploadStore(t *testing.T) { shards[i] = testutil.RandomCID(t) } - err := store.Upsert(t.Context(), space, root, shards, cause) + err := store.Upsert(t.Context(), space, root, &index, shards, cause) require.NoError(t, err) // list with a limit of 2 - should return first 2 and a cursor