From 01c390420ddb433db15427632a7eb8ae00141c71 Mon Sep 17 00:00:00 2001 From: Vicente Olmedo Date: Thu, 15 Jan 2026 16:50:01 +0100 Subject: [PATCH 1/5] feat: add account/egress/get capability --- capabilities/account/egress/egress.ipldsch | 29 +++++ capabilities/account/egress/get.go | 144 +++++++++++++++++++++ capabilities/account/egress/get_test.go | 91 +++++++++++++ capabilities/account/egress/schema.go | 34 +++++ 4 files changed, 298 insertions(+) create mode 100644 capabilities/account/egress/egress.ipldsch create mode 100644 capabilities/account/egress/get.go create mode 100644 capabilities/account/egress/get_test.go create mode 100644 capabilities/account/egress/schema.go diff --git a/capabilities/account/egress/egress.ipldsch b/capabilities/account/egress/egress.ipldsch new file mode 100644 index 0000000..c57fed0 --- /dev/null +++ b/capabilities/account/egress/egress.ipldsch @@ -0,0 +1,29 @@ +type GetCaveats struct { + spaces optional [DID] + period optional Period +} + +type Period struct { + from ISO8601Date + to ISO8601Date +} + +type GetOk struct { + total Int + spaces {DID:SpaceEgress} +} + +type SpaceEgress struct { + total Int + dailyStats [DailyStat] +} + +type DailyStat struct { + date ISO8601Date + egress Int +} + +type GetError struct { + errorName String (rename "name") + message String +} diff --git a/capabilities/account/egress/get.go b/capabilities/account/egress/get.go new file mode 100644 index 0000000..4d765d1 --- /dev/null +++ b/capabilities/account/egress/get.go @@ -0,0 +1,144 @@ +package egress + +import ( + "fmt" + "slices" + "time" + + "github.com/ipld/go-ipld-prime/datamodel" + "github.com/storacha/go-ucanto/core/ipld" + "github.com/storacha/go-ucanto/core/receipt" + "github.com/storacha/go-ucanto/core/result/failure" + "github.com/storacha/go-ucanto/core/schema" + "github.com/storacha/go-ucanto/did" + "github.com/storacha/go-ucanto/ucan" + "github.com/storacha/go-ucanto/validator" + + "github.com/storacha/go-libstoracha/capabilities/types" +) + +const GetAbility = "account/egress/get" + +type Period struct { + From time.Time + To time.Time +} + +type GetCaveats struct { + Spaces []did.DID + Period *Period +} + +func (gc GetCaveats) ToIPLD() (datamodel.Node, error) { + return ipld.WrapWithRecovery(&gc, GetCaveatsType(), types.Converters...) +} + +var GetCaveatsReader = schema.Struct[GetCaveats](GetCaveatsType(), nil, types.Converters...) + +type DailyStats struct { + Date time.Time + Egress uint64 +} + +type SpaceEgress struct { + Total uint64 + DailyStats []DailyStats +} + +type SpacesModel struct { + Keys []did.DID + Values map[did.DID]SpaceEgress +} + +type GetOk struct { + Total uint64 + Spaces SpacesModel +} + +func (gok GetOk) ToIPLD() (datamodel.Node, error) { + return ipld.WrapWithRecovery(&gok, GetOkType(), types.Converters...) +} + +type GetError struct { + ErrorName string + Message string +} + +const AccountNotFoundErrorName = "AccountNotFoundError" + +func NewAccountNotFoundError(msg string) GetError { + return GetError{ + ErrorName: AccountNotFoundErrorName, + Message: msg, + } +} + +func (ge GetError) Name() string { + return ge.ErrorName +} + +func (ge GetError) Error() string { + return ge.Message +} + +func (ge GetError) ToIPLD() (datamodel.Node, error) { + return ipld.WrapWithRecovery(&ge, GetErrorType(), types.Converters...) +} + +var GetErrorReader = schema.Mapped( + schema.Struct[GetError](GetErrorType(), nil, types.Converters...), + func(ge GetError) (GetError, failure.Failure) { + if ge.Name() != AccountNotFoundErrorName { + return GetError{}, failure.FromError(fmt.Errorf("incorrect name: %s, expected: %s", ge.Name(), AccountNotFoundErrorName)) + } + return ge, nil + }, +) + +type GetReceipt receipt.Receipt[GetOk, GetError] +type GetReceiptReader receipt.ReceiptReader[GetOk, GetError] + +func NewGetReceiptReader() (GetReceiptReader, error) { + return receipt.NewReceiptReaderFromTypes[GetOk, GetError](GetOkType(), GetErrorType(), types.Converters...) +} + +var GetOkReader = schema.Struct[GetOk](GetOkType(), nil, types.Converters...) + +var Get = validator.NewCapability( + GetAbility, + schema.DIDString(), + GetCaveatsReader, + getDerives, +) + +func getDerives(claimed, delegated ucan.Capability[GetCaveats]) failure.Failure { + if claimed.With() != delegated.With() { + return failure.FromError(fmt.Errorf("Can not derive %s with %s from %s", claimed.Can(), claimed.With(), delegated.With())) + } + + if delegated.Nb().Spaces != nil { + if claimed.Nb().Spaces == nil { + return failure.FromError(fmt.Errorf("Constraint violation: violates imposed spaces constraint %v because it asks for all spaces", delegated.Nb().Spaces)) + } + + for _, s := range claimed.Nb().Spaces { + if !slices.Contains(delegated.Nb().Spaces, s) { + return failure.FromError(fmt.Errorf("Constraint violation: violates imposed spaces constraint %v because it asks for space %s", delegated.Nb().Spaces, s)) + } + } + } + + if delegated.Nb().Period != nil { + if claimed.Nb().Period == nil { + return failure.FromError(fmt.Errorf("Constraint violation: violates imposed period constraint %v because it doesn't have a period constraint", delegated.Nb().Period)) + } + if claimed.Nb().Period.From.Before(delegated.Nb().Period.From) { + return failure.FromError(fmt.Errorf("Constraint violation: violates imposed period constraint because it requests dates before %s", delegated.Nb().Period.From)) + } + if claimed.Nb().Period.To.After(delegated.Nb().Period.To) { + return failure.FromError(fmt.Errorf("Constraint violation: violates imposed period constraint because it requests dates after %s", delegated.Nb().Period.To)) + } + } + + return nil +} diff --git a/capabilities/account/egress/get_test.go b/capabilities/account/egress/get_test.go new file mode 100644 index 0000000..ff6c5d2 --- /dev/null +++ b/capabilities/account/egress/get_test.go @@ -0,0 +1,91 @@ +package egress_test + +import ( + "testing" + "time" + + "github.com/storacha/go-libstoracha/capabilities/account/egress" + "github.com/storacha/go-libstoracha/testutil" + "github.com/storacha/go-ucanto/did" + + "github.com/stretchr/testify/require" +) + +func TestRoundTripGetCaveats(t *testing.T) { + t.Run("marshals and unmarshals correctly", func(t *testing.T) { + space1 := testutil.RandomDID(t) + space2 := testutil.RandomDID(t) + + nb := egress.GetCaveats{ + Spaces: []did.DID{space1, space2}, + Period: &egress.Period{ + From: time.UnixMilli(123000).UTC(), + To: time.UnixMilli(456000).UTC(), + }, + } + + node, err := nb.ToIPLD() + require.NoError(t, err) + + rnb, err := egress.GetCaveatsReader.Read(node) + require.NoError(t, err) + require.Equal(t, nb.Spaces[0].String(), rnb.Spaces[0].String()) + require.Equal(t, nb.Spaces[1].String(), rnb.Spaces[1].String()) + require.True(t, nb.Period.From.Equal(rnb.Period.From)) + require.True(t, nb.Period.To.Equal(rnb.Period.To)) + }) + + t.Run("properties are optional", func(t *testing.T) { + nb := egress.GetCaveats{} + + node, err := nb.ToIPLD() + require.NoError(t, err) + + rnb, err := egress.GetCaveatsReader.Read(node) + require.NoError(t, err) + require.Nil(t, rnb.Spaces) + require.Nil(t, rnb.Period) + }) +} + +func TestNewGetReceiptReader(t *testing.T) { + _, err := egress.NewGetReceiptReader() + require.NoError(t, err) +} + +func TestRoundTripGetOk(t *testing.T) { + space1 := testutil.RandomDID(t) + + ok := egress.GetOk{ + Total: 1000, + Spaces: egress.SpacesModel{ + Keys: []did.DID{space1}, + Values: map[did.DID]egress.SpaceEgress{ + space1: { + Total: 500, + DailyStats: []egress.DailyStats{ + { + Date: time.Now().Truncate(time.Second).UTC(), + Egress: 250, + }, + { + Date: time.Now().Add(-24 * time.Hour).Truncate(time.Second).UTC(), + Egress: 250, + }, + }, + }, + }, + }, + } + + node, err := ok.ToIPLD() + require.NoError(t, err) + + rok, err := egress.GetOkReader.Read(node) + require.NoError(t, err) + require.Equal(t, ok.Total, rok.Total) + require.Equal(t, len(ok.Spaces.Keys), len(rok.Spaces.Keys)) + require.Equal(t, ok.Spaces.Values[space1].Total, rok.Spaces.Values[space1].Total) + require.Equal(t, len(ok.Spaces.Values[space1].DailyStats), len(rok.Spaces.Values[space1].DailyStats)) + require.True(t, ok.Spaces.Values[space1].DailyStats[0].Date.Equal(rok.Spaces.Values[space1].DailyStats[0].Date)) +} diff --git a/capabilities/account/egress/schema.go b/capabilities/account/egress/schema.go new file mode 100644 index 0000000..4eff1f6 --- /dev/null +++ b/capabilities/account/egress/schema.go @@ -0,0 +1,34 @@ +package egress + +import ( + _ "embed" + "fmt" + + "github.com/ipld/go-ipld-prime/schema" + captypes "github.com/storacha/go-libstoracha/capabilities/types" +) + +//go:embed egress.ipldsch +var egressSchema []byte + +var egressTS = mustLoadTS() + +func mustLoadTS() *schema.TypeSystem { + ts, err := captypes.LoadSchemaBytes(egressSchema) + if err != nil { + panic(fmt.Errorf("loading egress schema: %w", err)) + } + return ts +} + +func GetCaveatsType() schema.Type { + return egressTS.TypeByName("GetCaveats") +} + +func GetOkType() schema.Type { + return egressTS.TypeByName("GetOk") +} + +func GetErrorType() schema.Type { + return egressTS.TypeByName("GetError") +} From ed07e16f71830cb9a93f1951d58f99cba7c5bc6a Mon Sep 17 00:00:00 2001 From: Vicente Olmedo Date: Mon, 19 Jan 2026 14:40:35 +0100 Subject: [PATCH 2/5] lint --- capabilities/account/egress/get.go | 12 ++++++------ capabilities/account/egress/get_test.go | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/capabilities/account/egress/get.go b/capabilities/account/egress/get.go index 4d765d1..1bfea91 100644 --- a/capabilities/account/egress/get.go +++ b/capabilities/account/egress/get.go @@ -113,30 +113,30 @@ var Get = validator.NewCapability( func getDerives(claimed, delegated ucan.Capability[GetCaveats]) failure.Failure { if claimed.With() != delegated.With() { - return failure.FromError(fmt.Errorf("Can not derive %s with %s from %s", claimed.Can(), claimed.With(), delegated.With())) + return failure.FromError(fmt.Errorf("can not derive %s with %s from %s", claimed.Can(), claimed.With(), delegated.With())) } if delegated.Nb().Spaces != nil { if claimed.Nb().Spaces == nil { - return failure.FromError(fmt.Errorf("Constraint violation: violates imposed spaces constraint %v because it asks for all spaces", delegated.Nb().Spaces)) + return failure.FromError(fmt.Errorf("constraint violation: violates imposed spaces constraint %v because it asks for all spaces", delegated.Nb().Spaces)) } for _, s := range claimed.Nb().Spaces { if !slices.Contains(delegated.Nb().Spaces, s) { - return failure.FromError(fmt.Errorf("Constraint violation: violates imposed spaces constraint %v because it asks for space %s", delegated.Nb().Spaces, s)) + return failure.FromError(fmt.Errorf("constraint violation: violates imposed spaces constraint %v because it asks for space %s", delegated.Nb().Spaces, s)) } } } if delegated.Nb().Period != nil { if claimed.Nb().Period == nil { - return failure.FromError(fmt.Errorf("Constraint violation: violates imposed period constraint %v because it doesn't have a period constraint", delegated.Nb().Period)) + return failure.FromError(fmt.Errorf("constraint violation: violates imposed period constraint %v because it doesn't have a period constraint", delegated.Nb().Period)) } if claimed.Nb().Period.From.Before(delegated.Nb().Period.From) { - return failure.FromError(fmt.Errorf("Constraint violation: violates imposed period constraint because it requests dates before %s", delegated.Nb().Period.From)) + return failure.FromError(fmt.Errorf("constraint violation: violates imposed period constraint because it requests dates before %s", delegated.Nb().Period.From)) } if claimed.Nb().Period.To.After(delegated.Nb().Period.To) { - return failure.FromError(fmt.Errorf("Constraint violation: violates imposed period constraint because it requests dates after %s", delegated.Nb().Period.To)) + return failure.FromError(fmt.Errorf("constraint violation: violates imposed period constraint because it requests dates after %s", delegated.Nb().Period.To)) } } diff --git a/capabilities/account/egress/get_test.go b/capabilities/account/egress/get_test.go index ff6c5d2..2780906 100644 --- a/capabilities/account/egress/get_test.go +++ b/capabilities/account/egress/get_test.go @@ -55,7 +55,7 @@ func TestNewGetReceiptReader(t *testing.T) { func TestRoundTripGetOk(t *testing.T) { space1 := testutil.RandomDID(t) - + ok := egress.GetOk{ Total: 1000, Spaces: egress.SpacesModel{ From e552dec1d1d289a423efd63594e847ed53ab8227 Mon Sep 17 00:00:00 2001 From: Vicente Olmedo Date: Mon, 19 Jan 2026 18:14:51 +0100 Subject: [PATCH 3/5] add comments --- capabilities/account/egress/get.go | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/capabilities/account/egress/get.go b/capabilities/account/egress/get.go index 1bfea91..e8d6430 100644 --- a/capabilities/account/egress/get.go +++ b/capabilities/account/egress/get.go @@ -19,11 +19,18 @@ import ( const GetAbility = "account/egress/get" +// Period is a time range to filter egress results (optional) +// From is inclusive and To is exclusive. +// Currently only the date portion of `time.Time` is used because the resolution of the data is always daily. type Period struct { From time.Time To time.Time } +// GetCaveats allows filtering the egress results. +// Both caveats are optional. +// An empty `Spaces` will return egress stats for all spaces owned by the account. +// A nil `Period` will return egress stats from the first day of the last complete month to today by default. type GetCaveats struct { Spaces []did.DID Period *Period @@ -35,11 +42,16 @@ func (gc GetCaveats) ToIPLD() (datamodel.Node, error) { var GetCaveatsReader = schema.Struct[GetCaveats](GetCaveatsType(), nil, types.Converters...) +// DailyStats contains the egress stats for a single day, in number of bytes. +// Only the date part of `time.Time` is used. type DailyStats struct { Date time.Time Egress uint64 } +// SpaceEgress contains the egress stats for a single space. +// Total is the total egress in bytes for the given period. +// DailyStats contains the stats for each day in the period. Sorted by date ascending. type SpaceEgress struct { Total uint64 DailyStats []DailyStats @@ -50,6 +62,9 @@ type SpacesModel struct { Values map[did.DID]SpaceEgress } +// GetOk contains the egress stats for the given period. +// Total is the total egress in bytes for the requested period. +// Spaces offers a detailed daily breakdown of the egress for each space in the requested period. type GetOk struct { Total uint64 Spaces SpacesModel From 142ad8b998ce65c98ac99b769bd9866c020f0b10 Mon Sep 17 00:00:00 2001 From: Vicente Olmedo Date: Tue, 20 Jan 2026 13:58:57 +0100 Subject: [PATCH 4/5] more errors --- capabilities/account/egress/get.go | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/capabilities/account/egress/get.go b/capabilities/account/egress/get.go index e8d6430..e20099f 100644 --- a/capabilities/account/egress/get.go +++ b/capabilities/account/egress/get.go @@ -88,6 +88,24 @@ func NewAccountNotFoundError(msg string) GetError { } } +const SpaceUnauthorizedErrorName = "SpaceUnauthorizedError" + +func NewSpaceUnauthorizedError(msg string) GetError { + return GetError{ + ErrorName: SpaceUnauthorizedErrorName, + Message: msg, + } +} + +const PeriodNotAcceptableErrorName = "PeriodNotAcceptableError" + +func NewPeriodNotAcceptableError(msg string) GetError { + return GetError{ + ErrorName: PeriodNotAcceptableErrorName, + Message: msg, + } +} + func (ge GetError) Name() string { return ge.ErrorName } From 9108f315eefe50179a7164638563f6d3c8c8296a Mon Sep 17 00:00:00 2001 From: Vicente Olmedo Date: Wed, 21 Jan 2026 14:33:05 +0100 Subject: [PATCH 5/5] remove redundant 'Error' suffix in error names --- capabilities/account/egress/get.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/capabilities/account/egress/get.go b/capabilities/account/egress/get.go index e20099f..b5abdfb 100644 --- a/capabilities/account/egress/get.go +++ b/capabilities/account/egress/get.go @@ -79,7 +79,7 @@ type GetError struct { Message string } -const AccountNotFoundErrorName = "AccountNotFoundError" +const AccountNotFoundErrorName = "AccountNotFound" func NewAccountNotFoundError(msg string) GetError { return GetError{ @@ -88,7 +88,7 @@ func NewAccountNotFoundError(msg string) GetError { } } -const SpaceUnauthorizedErrorName = "SpaceUnauthorizedError" +const SpaceUnauthorizedErrorName = "SpaceUnauthorized" func NewSpaceUnauthorizedError(msg string) GetError { return GetError{ @@ -97,7 +97,7 @@ func NewSpaceUnauthorizedError(msg string) GetError { } } -const PeriodNotAcceptableErrorName = "PeriodNotAcceptableError" +const PeriodNotAcceptableErrorName = "PeriodNotAcceptable" func NewPeriodNotAcceptableError(msg string) GetError { return GetError{