Merge pull request #49 from ucan-wg/feat/complete-invocation-stub
feat: complete invocation stub
This commit is contained in:
206
pkg/args/args.go
Normal file
206
pkg/args/args.go
Normal file
@@ -0,0 +1,206 @@
|
||||
// Package args provides the type that represents the Arguments passed to
|
||||
// a command within an invocation.Token as well as a convenient Add method
|
||||
// to incrementally build the underlying map.
|
||||
package args
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"sync"
|
||||
|
||||
"github.com/ipfs/go-cid"
|
||||
"github.com/ipld/go-ipld-prime"
|
||||
"github.com/ipld/go-ipld-prime/node/bindnode"
|
||||
"github.com/ipld/go-ipld-prime/schema"
|
||||
"github.com/ucan-wg/go-ucan/pkg/policy/literal"
|
||||
)
|
||||
|
||||
const (
|
||||
argsSchema = "type Args { String : Any }"
|
||||
argsName = "Args"
|
||||
)
|
||||
|
||||
var (
|
||||
once sync.Once
|
||||
ts *schema.TypeSystem
|
||||
err error
|
||||
)
|
||||
|
||||
func argsType() schema.Type {
|
||||
once.Do(func() {
|
||||
ts, err = ipld.LoadSchemaBytes([]byte(argsSchema))
|
||||
})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return ts.TypeByName(argsName)
|
||||
}
|
||||
|
||||
var ErrUnsupported = fmt.Errorf("failure adding unsupported type to meta")
|
||||
|
||||
// Args are the Command's argumennts when an invocation Token is processed
|
||||
// by the executor.
|
||||
//
|
||||
// This type must be compatible with the IPLD type represented by the IPLD
|
||||
// schema { String : Any }.
|
||||
type Args struct {
|
||||
Keys []string
|
||||
Values map[string]ipld.Node
|
||||
}
|
||||
|
||||
// New returns a pointer to an initialized Args value.
|
||||
func New() *Args {
|
||||
return &Args{
|
||||
Values: map[string]ipld.Node{},
|
||||
}
|
||||
}
|
||||
|
||||
// FromIPLD unwraps an Args instance from an ipld.Node.
|
||||
func FromIPLD(node ipld.Node) (*Args, error) {
|
||||
var err error
|
||||
|
||||
defer func() {
|
||||
err = handlePanic(recover())
|
||||
}()
|
||||
|
||||
obj := bindnode.Unwrap(node)
|
||||
|
||||
args, ok := obj.(*Args)
|
||||
if !ok {
|
||||
err = fmt.Errorf("failed to convert to Args")
|
||||
}
|
||||
|
||||
return args, err
|
||||
}
|
||||
|
||||
// Add inserts a key/value pair in the Args set.
|
||||
//
|
||||
// Accepted types for val are: bool, string, int, int8, int16,
|
||||
// int32, int64, uint, uint8, uint16, uint32, float32, float64, []byte,
|
||||
// []any, map[string]any, ipld.Node and nil.
|
||||
func (m *Args) Add(key string, val any) error {
|
||||
if _, ok := m.Values[key]; ok {
|
||||
return fmt.Errorf("duplicate key %q", key)
|
||||
}
|
||||
|
||||
node, err := anyNode(val)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
m.Values[key] = node
|
||||
m.Keys = append(m.Keys, key)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Include merges the provided arguments into the existing arguments.
|
||||
//
|
||||
// If duplicate keys are encountered, the new value is silently dropped
|
||||
// without causing an error.
|
||||
func (m *Args) Include(other *Args) {
|
||||
for _, key := range other.Keys {
|
||||
if _, ok := m.Values[key]; ok {
|
||||
// don't overwrite
|
||||
continue
|
||||
}
|
||||
m.Values[key] = other.Values[key]
|
||||
m.Keys = append(m.Keys, key)
|
||||
}
|
||||
}
|
||||
|
||||
// ToIPLD wraps an instance of an Args with an ipld.Node.
|
||||
func (m *Args) ToIPLD() (ipld.Node, error) {
|
||||
var err error
|
||||
|
||||
defer func() {
|
||||
err = handlePanic(recover())
|
||||
}()
|
||||
|
||||
return bindnode.Wrap(m, argsType()), err
|
||||
}
|
||||
|
||||
func anyNode(val any) (ipld.Node, error) {
|
||||
var err error
|
||||
|
||||
defer func() {
|
||||
err = handlePanic(recover())
|
||||
}()
|
||||
|
||||
if val == nil {
|
||||
return literal.Null(), nil
|
||||
}
|
||||
|
||||
if cast, ok := val.(ipld.Node); ok {
|
||||
return cast, nil
|
||||
}
|
||||
|
||||
if cast, ok := val.(cid.Cid); ok {
|
||||
return literal.LinkCid(cast), err
|
||||
}
|
||||
|
||||
var rv reflect.Value
|
||||
|
||||
rv.Kind()
|
||||
|
||||
if cast, ok := val.(reflect.Value); ok {
|
||||
rv = cast
|
||||
} else {
|
||||
rv = reflect.ValueOf(val)
|
||||
}
|
||||
|
||||
for rv.Kind() == reflect.Ptr || rv.Kind() == reflect.Interface {
|
||||
rv = rv.Elem()
|
||||
}
|
||||
|
||||
switch rv.Kind() {
|
||||
case reflect.Slice:
|
||||
if rv.Type().Elem().Kind() == reflect.Uint8 {
|
||||
return literal.Bytes(val.([]byte)), nil
|
||||
}
|
||||
|
||||
l := make([]reflect.Value, rv.Len())
|
||||
|
||||
for i := 0; i < rv.Len(); i++ {
|
||||
l[i] = rv.Index(i)
|
||||
}
|
||||
|
||||
return literal.List(l)
|
||||
case reflect.Map:
|
||||
if rv.Type().Key().Kind() != reflect.String {
|
||||
return nil, fmt.Errorf("unsupported map key type: %s", rv.Type().Key().Name())
|
||||
}
|
||||
|
||||
m := make(map[string]reflect.Value, rv.Len())
|
||||
it := rv.MapRange()
|
||||
|
||||
for it.Next() {
|
||||
m[it.Key().String()] = it.Value()
|
||||
}
|
||||
|
||||
return literal.Map(m)
|
||||
case reflect.String:
|
||||
return literal.String(rv.String()), nil
|
||||
case reflect.Bool:
|
||||
return literal.Bool(rv.Bool()), nil
|
||||
// reflect.Int64 may exceed the safe 53-bit limit of JavaScript
|
||||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||
return literal.Int(rv.Int()), nil
|
||||
// reflect.Uint64 can't be safely converted to int64
|
||||
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32:
|
||||
return literal.Int(int64(rv.Uint())), nil
|
||||
case reflect.Float32, reflect.Float64:
|
||||
return literal.Float(rv.Float()), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported Args type: %s", rv.Type().Name())
|
||||
}
|
||||
}
|
||||
|
||||
func handlePanic(rec any) error {
|
||||
if err, ok := rec.(error); ok {
|
||||
return err
|
||||
}
|
||||
|
||||
return fmt.Errorf("%v", rec)
|
||||
}
|
||||
192
pkg/args/args_test.go
Normal file
192
pkg/args/args_test.go
Normal file
@@ -0,0 +1,192 @@
|
||||
package args_test
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/ipfs/go-cid"
|
||||
"github.com/ipld/go-ipld-prime"
|
||||
"github.com/ipld/go-ipld-prime/codec/dagcbor"
|
||||
"github.com/ipld/go-ipld-prime/datamodel"
|
||||
"github.com/ipld/go-ipld-prime/node/bindnode"
|
||||
"github.com/ipld/go-ipld-prime/schema"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/ucan-wg/go-ucan/pkg/args"
|
||||
"github.com/ucan-wg/go-ucan/pkg/policy/literal"
|
||||
)
|
||||
|
||||
const (
|
||||
argsSchema = "type Args { String : Any }"
|
||||
argsName = "Args"
|
||||
)
|
||||
|
||||
var (
|
||||
once sync.Once
|
||||
ts *schema.TypeSystem
|
||||
err error
|
||||
)
|
||||
|
||||
func argsType() schema.Type {
|
||||
once.Do(func() {
|
||||
ts, err = ipld.LoadSchemaBytes([]byte(argsSchema))
|
||||
})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return ts.TypeByName(argsName)
|
||||
}
|
||||
|
||||
func argsPrototype() schema.TypedPrototype {
|
||||
return bindnode.Prototype((*args.Args)(nil), argsType())
|
||||
}
|
||||
|
||||
func TestArgs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const (
|
||||
intKey = "intKey"
|
||||
mapKey = "mapKey"
|
||||
nilKey = "nilKey"
|
||||
boolKey = "boolKey"
|
||||
linkKey = "linkKey"
|
||||
listKey = "listKey"
|
||||
nodeKey = "nodeKey"
|
||||
uintKey = "uintKey"
|
||||
bytesKey = "bytesKey"
|
||||
floatKey = "floatKey"
|
||||
stringKey = "stringKey"
|
||||
)
|
||||
|
||||
const (
|
||||
expIntVal = int64(-42)
|
||||
expBoolVal = true
|
||||
expUintVal = uint(42)
|
||||
expStringVal = "stringVal"
|
||||
)
|
||||
|
||||
var (
|
||||
expMapVal = map[string]string{"keyOne": "valOne", "keyTwo": "valTwo"}
|
||||
// expNilVal = (map[string]string)(nil)
|
||||
expLinkVal = cid.MustParse("bafzbeigai3eoy2ccc7ybwjfz5r3rdxqrinwi4rwytly24tdbh6yk7zslrm")
|
||||
expListVal = []string{"elem1", "elem2", "elem3"}
|
||||
expNodeVal = literal.String("nodeVal")
|
||||
expBytesVal = []byte{0xde, 0xad, 0xbe, 0xef}
|
||||
expFloatVal = 42.0
|
||||
)
|
||||
|
||||
argsIn := args.New()
|
||||
|
||||
// WARNING: Do not change the order of these statements as this is the
|
||||
// order which will be present when decoded from DAG-CBOR (
|
||||
// per RFC7049 default canonical ordering?).
|
||||
for _, a := range []struct {
|
||||
key string
|
||||
val any
|
||||
}{
|
||||
{key: intKey, val: expIntVal},
|
||||
{key: mapKey, val: expMapVal},
|
||||
// {key: nilKey, val: expNilVal},
|
||||
{key: boolKey, val: expBoolVal},
|
||||
{key: linkKey, val: expLinkVal},
|
||||
{key: listKey, val: expListVal},
|
||||
{key: nodeKey, val: expNodeVal},
|
||||
{key: uintKey, val: expUintVal},
|
||||
{key: bytesKey, val: expBytesVal},
|
||||
{key: floatKey, val: expFloatVal},
|
||||
{key: stringKey, val: expStringVal},
|
||||
} {
|
||||
require.NoError(t, argsIn.Add(a.key, a.val))
|
||||
}
|
||||
|
||||
// Round-trip to DAG-CBOR here as ToIPLD/FromIPLD is only a wrapper
|
||||
argsOut := roundTripThroughDAGCBOR(t, argsIn)
|
||||
assert.Equal(t, argsIn, argsOut)
|
||||
|
||||
actMapVal := map[string]string{}
|
||||
mit := argsOut.Values[mapKey].MapIterator()
|
||||
|
||||
es := errorSwallower(t)
|
||||
|
||||
for !mit.Done() {
|
||||
k, v, err := mit.Next()
|
||||
require.NoError(t, err)
|
||||
ks := es(k.AsString()).(string)
|
||||
vs := es(v.AsString()).(string)
|
||||
|
||||
actMapVal[ks] = vs
|
||||
}
|
||||
|
||||
actListVal := []string{}
|
||||
lit := argsOut.Values[listKey].ListIterator()
|
||||
|
||||
for !lit.Done() {
|
||||
_, v, err := lit.Next()
|
||||
require.NoError(t, err)
|
||||
vs := es(v.AsString()).(string)
|
||||
|
||||
actListVal = append(actListVal, vs)
|
||||
}
|
||||
|
||||
assert.Equal(t, expIntVal, es(argsOut.Values[intKey].AsInt()))
|
||||
assert.Equal(t, expMapVal, actMapVal) // TODO: special accessor
|
||||
// TODO: the nil map comes back empty (but the right type)
|
||||
// assert.Equal(t, expNilVal, actNilVal)
|
||||
assert.Equal(t, expBoolVal, es(argsOut.Values[boolKey].AsBool()))
|
||||
assert.Equal(t, expLinkVal.String(), es(argsOut.Values[linkKey].AsLink()).(datamodel.Link).String()) // TODO: special accessor
|
||||
assert.Equal(t, expListVal, actListVal) // TODO: special accessor
|
||||
assert.Equal(t, expNodeVal, argsOut.Values[nodeKey])
|
||||
assert.Equal(t, expUintVal, uint(es(argsOut.Values[uintKey].AsInt()).(int64)))
|
||||
assert.Equal(t, expBytesVal, es(argsOut.Values[bytesKey].AsBytes()))
|
||||
assert.Equal(t, expFloatVal, es(argsOut.Values[floatKey].AsFloat()))
|
||||
assert.Equal(t, expStringVal, es(argsOut.Values[stringKey].AsString()))
|
||||
}
|
||||
|
||||
func TestArgs_Include(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
argsIn := args.New()
|
||||
require.NoError(t, argsIn.Add("key1", "val1"))
|
||||
require.NoError(t, argsIn.Add("key2", "val2"))
|
||||
|
||||
argsOther := args.New()
|
||||
require.NoError(t, argsOther.Add("key2", "valOther")) // This should not overwrite key2 above
|
||||
require.NoError(t, argsOther.Add("key3", "val3"))
|
||||
require.NoError(t, argsOther.Add("key4", "val4"))
|
||||
|
||||
argsIn.Include(argsOther)
|
||||
|
||||
es := errorSwallower(t)
|
||||
|
||||
assert.Len(t, argsIn.Values, 4)
|
||||
assert.Equal(t, "val1", es(argsIn.Values["key1"].AsString()))
|
||||
assert.Equal(t, "val2", es(argsIn.Values["key2"].AsString()))
|
||||
assert.Equal(t, "val3", es(argsIn.Values["key3"].AsString()))
|
||||
assert.Equal(t, "val4", es(argsIn.Values["key4"].AsString()))
|
||||
}
|
||||
|
||||
func errorSwallower(t *testing.T) func(any, error) any {
|
||||
return func(val any, err error) any {
|
||||
require.NoError(t, err)
|
||||
|
||||
return val
|
||||
}
|
||||
}
|
||||
|
||||
func roundTripThroughDAGCBOR(t *testing.T, argsIn *args.Args) *args.Args {
|
||||
t.Helper()
|
||||
|
||||
node, err := argsIn.ToIPLD()
|
||||
require.NoError(t, err)
|
||||
|
||||
data, err := ipld.Encode(node, dagcbor.Encode)
|
||||
require.NoError(t, err)
|
||||
node, err = ipld.DecodeUsingPrototype(data, dagcbor.Decode, argsPrototype())
|
||||
require.NoError(t, err)
|
||||
|
||||
argsOut, err := args.FromIPLD(node)
|
||||
require.NoError(t, err)
|
||||
|
||||
return argsOut
|
||||
}
|
||||
@@ -98,6 +98,9 @@ func (m *Meta) GetNode(key string) (ipld.Node, error) {
|
||||
// Accepted types for the value are: bool, string, int, int32, int64, []byte,
|
||||
// and ipld.Node.
|
||||
func (m *Meta) Add(key string, val any) error {
|
||||
if _, ok := m.Values[key]; ok {
|
||||
return fmt.Errorf("duplicate key %q", key)
|
||||
}
|
||||
switch val := val.(type) {
|
||||
case bool:
|
||||
m.Values[key] = basicnode.NewBool(val)
|
||||
|
||||
@@ -7,8 +7,11 @@ import (
|
||||
"github.com/ipfs/go-cid"
|
||||
"github.com/ipld/go-ipld-prime"
|
||||
"github.com/ipld/go-ipld-prime/codec/dagjson"
|
||||
"github.com/ipld/go-ipld-prime/datamodel"
|
||||
"github.com/ipld/go-ipld-prime/fluent/qp"
|
||||
cidlink "github.com/ipld/go-ipld-prime/linking/cid"
|
||||
"github.com/ipld/go-ipld-prime/node/basicnode"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/ucan-wg/go-ucan/pkg/policy/literal"
|
||||
@@ -901,3 +904,55 @@ func TestPartialMatch(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestInvocationValidation applies the example policy to the second
|
||||
// example arguments as defined in the [Validation] section of the
|
||||
// invocation specification.
|
||||
//
|
||||
// [Validation]: https://github.com/ucan-wg/delegation/tree/v1_ipld#validation
|
||||
func TestInvocationValidationSpecExamples(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
pol := MustConstruct(
|
||||
Equal(".from", literal.String("alice@example.com")),
|
||||
Any(".to", Like(".", "*@example.com")),
|
||||
)
|
||||
|
||||
t.Run("with passing args", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
argsNode, err := qp.BuildMap(basicnode.Prototype.Any, 2, func(ma datamodel.MapAssembler) {
|
||||
qp.MapEntry(ma, "from", qp.String("alice@example.com"))
|
||||
qp.MapEntry(ma, "to", qp.List(2, func(la datamodel.ListAssembler) {
|
||||
qp.ListEntry(la, qp.String("bob@example.com"))
|
||||
qp.ListEntry(la, qp.String("carol@not.example.com"))
|
||||
}))
|
||||
qp.MapEntry(ma, "title", qp.String("Coffee"))
|
||||
qp.MapEntry(ma, "body", qp.String("Still on for coffee"))
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
exec, stmt := pol.Match(argsNode)
|
||||
assert.True(t, exec)
|
||||
assert.Nil(t, stmt)
|
||||
})
|
||||
|
||||
t.Run("fails on recipients (second statement)", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
argsNode, err := qp.BuildMap(basicnode.Prototype.Any, 2, func(ma datamodel.MapAssembler) {
|
||||
qp.MapEntry(ma, "from", qp.String("alice@example.com"))
|
||||
qp.MapEntry(ma, "to", qp.List(2, func(la datamodel.ListAssembler) {
|
||||
qp.ListEntry(la, qp.String("bob@null.com"))
|
||||
qp.ListEntry(la, qp.String("carol@elsewhere.example.com"))
|
||||
}))
|
||||
qp.MapEntry(ma, "title", qp.String("Coffee"))
|
||||
qp.MapEntry(ma, "body", qp.String("Still on for coffee"))
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
exec, stmt := pol.Match(argsNode)
|
||||
assert.False(t, exec)
|
||||
assert.NotNil(t, stmt)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
"github.com/ucan-wg/go-ucan/pkg/command"
|
||||
"github.com/ucan-wg/go-ucan/pkg/meta"
|
||||
"github.com/ucan-wg/go-ucan/pkg/policy"
|
||||
"github.com/ucan-wg/go-ucan/token/internal/parse"
|
||||
)
|
||||
|
||||
// Token is an immutable type that holds the fields of a UCAN delegation.
|
||||
@@ -184,27 +185,19 @@ func tokenFromModel(m tokenPayloadModel) (*Token, error) {
|
||||
return nil, fmt.Errorf("parse iss: %w", err)
|
||||
}
|
||||
|
||||
tkn.audience, err = did.Parse(m.Aud)
|
||||
if err != nil {
|
||||
if tkn.audience, err = did.Parse(m.Aud); err != nil {
|
||||
return nil, fmt.Errorf("parse audience: %w", err)
|
||||
}
|
||||
|
||||
if m.Sub != nil {
|
||||
tkn.subject, err = did.Parse(*m.Sub)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse subject: %w", err)
|
||||
}
|
||||
} else {
|
||||
tkn.subject = did.Undef
|
||||
if tkn.subject, err = parse.OptionalDID(m.Sub); err != nil {
|
||||
return nil, fmt.Errorf("parse subject: %w", err)
|
||||
}
|
||||
|
||||
tkn.command, err = command.Parse(m.Cmd)
|
||||
if err != nil {
|
||||
if tkn.command, err = command.Parse(m.Cmd); err != nil {
|
||||
return nil, fmt.Errorf("parse command: %w", err)
|
||||
}
|
||||
|
||||
tkn.policy, err = policy.FromIPLD(m.Pol)
|
||||
if err != nil {
|
||||
if tkn.policy, err = policy.FromIPLD(m.Pol); err != nil {
|
||||
return nil, fmt.Errorf("parse policy: %w", err)
|
||||
}
|
||||
|
||||
@@ -215,15 +208,8 @@ func tokenFromModel(m tokenPayloadModel) (*Token, error) {
|
||||
|
||||
tkn.meta = m.Meta
|
||||
|
||||
if m.Nbf != nil {
|
||||
t := time.Unix(*m.Nbf, 0)
|
||||
tkn.notBefore = &t
|
||||
}
|
||||
|
||||
if m.Exp != nil {
|
||||
t := time.Unix(*m.Exp, 0)
|
||||
tkn.expiration = &t
|
||||
}
|
||||
tkn.notBefore = parse.OptionalTimestamp(m.Nbf)
|
||||
tkn.expiration = parse.OptionalTimestamp(m.Exp)
|
||||
|
||||
if err := tkn.validate(); err != nil {
|
||||
return nil, err
|
||||
|
||||
22
token/internal/parse/parse.go
Normal file
22
token/internal/parse/parse.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package parse
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/ucan-wg/go-ucan/did"
|
||||
)
|
||||
|
||||
func OptionalDID(s *string) (did.DID, error) {
|
||||
if s == nil {
|
||||
return did.Undef, nil
|
||||
}
|
||||
return did.Parse(*s)
|
||||
}
|
||||
|
||||
func OptionalTimestamp(sec *int64) *time.Time {
|
||||
if sec == nil {
|
||||
return nil
|
||||
}
|
||||
t := time.Unix(*sec, 0)
|
||||
return &t
|
||||
}
|
||||
228
token/invocation/examples_test.go
Normal file
228
token/invocation/examples_test.go
Normal file
@@ -0,0 +1,228 @@
|
||||
package invocation_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/ipfs/go-cid"
|
||||
"github.com/ipld/go-ipld-prime"
|
||||
"github.com/ipld/go-ipld-prime/codec/dagcbor"
|
||||
"github.com/ipld/go-ipld-prime/codec/dagjson"
|
||||
"github.com/ipld/go-ipld-prime/datamodel"
|
||||
"github.com/ipld/go-ipld-prime/fluent/qp"
|
||||
"github.com/ipld/go-ipld-prime/node/basicnode"
|
||||
"github.com/libp2p/go-libp2p/core/crypto"
|
||||
|
||||
"github.com/ucan-wg/go-ucan/did"
|
||||
"github.com/ucan-wg/go-ucan/pkg/command"
|
||||
"github.com/ucan-wg/go-ucan/token/invocation"
|
||||
)
|
||||
|
||||
func ExampleNew() {
|
||||
privKey, iss, sub, cmd, args, prf, meta, err := setupExampleNew()
|
||||
if err != nil {
|
||||
fmt.Println("failed to create setup:", err.Error())
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
inv, err := invocation.New(iss, sub, cmd, prf,
|
||||
invocation.WithArgument("uri", args["uri"]),
|
||||
invocation.WithArgument("headers", args["headers"]),
|
||||
invocation.WithArgument("payload", args["payload"]),
|
||||
invocation.WithMeta("env", "development"),
|
||||
invocation.WithMeta("tags", meta["tags"]),
|
||||
invocation.WithExpirationIn(time.Minute),
|
||||
invocation.WithoutInvokedAt())
|
||||
if err != nil {
|
||||
fmt.Println("failed to create invocation:", err.Error())
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
data, cid, err := inv.ToSealed(privKey)
|
||||
if err != nil {
|
||||
fmt.Println("failed to seal invocation:", err.Error())
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
json, err := prettyDAGJSON(data)
|
||||
if err != nil {
|
||||
fmt.Println("failed to pretty DAG-JSON:", err.Error())
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Println("CID:", cid)
|
||||
fmt.Println("Token (pretty DAG-JSON):")
|
||||
fmt.Println(json)
|
||||
|
||||
// Expected CID and DAG-JSON output:
|
||||
// CID: bafyreid2n5q45vk4osned7k5huocbe3mxbisonh5vujepqftc5ftr543ae
|
||||
// Token (pretty DAG-JSON):
|
||||
// [
|
||||
// {
|
||||
// "/": {
|
||||
// "bytes": "gvyL7kdSkgmaDpDU/Qj9ohRwxYLCHER52HFMSFEqQqEcQC9qr4JCPP1f/WybvGGuVzYiA0Hx4JO+ohNz8BxUAA"
|
||||
// }
|
||||
// },
|
||||
// {
|
||||
// "h": {
|
||||
// "/": {
|
||||
// "bytes": "NO0BcQ"
|
||||
// }
|
||||
// },
|
||||
// "ucan/inv@1.0.0-rc.1": {
|
||||
// "args": {
|
||||
// "headers": {
|
||||
// "Content-Type": "application/json"
|
||||
// },
|
||||
// "payload": {
|
||||
// "body": "UCAN is great",
|
||||
// "draft": true,
|
||||
// "title": "UCAN for Fun and Profit",
|
||||
// "topics": [
|
||||
// "authz",
|
||||
// "journal"
|
||||
// ]
|
||||
// },
|
||||
// "uri": "https://example.com/blog/posts"
|
||||
// },
|
||||
// "cmd": "/crud/create",
|
||||
// "exp": 1729788921,
|
||||
// "iss": "did:key:z6MkhniGGyP88eZrq2dpMvUPdS2RQMhTUAWzcu6kVGUvEtCJ",
|
||||
// "meta": {
|
||||
// "env": "development",
|
||||
// "tags": [
|
||||
// "blog",
|
||||
// "post",
|
||||
// "pr#123"
|
||||
// ]
|
||||
// },
|
||||
// "nonce": {
|
||||
// "/": {
|
||||
// "bytes": "2xXPoZwWln1TfXIp"
|
||||
// }
|
||||
// },
|
||||
// "prf": [
|
||||
// {
|
||||
// "/": "bafyreigx3qxd2cndpe66j2mdssj773ecv7tqd7wovcnz5raguw6lj7sjoe"
|
||||
// },
|
||||
// {
|
||||
// "/": "bafyreib34ira254zdqgehz6f2bhwme2ja2re3ltcalejv4x4tkcveujvpa"
|
||||
// },
|
||||
// {
|
||||
// "/": "bafyreibkb66tpo2ixqx3fe5hmekkbuasrod6olt5bwm5u5pi726mduuwlq"
|
||||
// }
|
||||
// ],
|
||||
// "sub": "did:key:z6MktWuvPvBe5UyHnDGuEdw8aJ5qrhhwLG6jy7cQYM6ckP6P"
|
||||
// }
|
||||
// }
|
||||
// ]
|
||||
}
|
||||
|
||||
func prettyDAGJSON(data []byte) (string, error) {
|
||||
var node ipld.Node
|
||||
|
||||
node, err := ipld.Decode(data, dagcbor.Decode)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
jsonData, err := ipld.Encode(node, dagjson.Encode)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
if err := json.Indent(&out, jsonData, "", " "); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return out.String(), nil
|
||||
}
|
||||
|
||||
func setupExampleNew() (privKey crypto.PrivKey, iss, sub did.DID, cmd command.Command, args map[string]datamodel.Node, prf []cid.Cid, meta map[string]datamodel.Node, errs error) {
|
||||
var err error
|
||||
|
||||
privKey, iss, err = did.GenerateEd25519()
|
||||
if err != nil {
|
||||
errs = errors.Join(errs, fmt.Errorf("failed to generate Issuer identity: %w", err))
|
||||
}
|
||||
|
||||
_, sub, err = did.GenerateEd25519()
|
||||
if err != nil {
|
||||
errs = errors.Join(errs, fmt.Errorf("failed to generate Subject identity: %w", err))
|
||||
}
|
||||
|
||||
cmd, err = command.Parse("/crud/create")
|
||||
if err != nil {
|
||||
errs = errors.Join(errs, fmt.Errorf("failed to parse command: %w", err))
|
||||
}
|
||||
|
||||
headers, err := qp.BuildMap(basicnode.Prototype.Any, 2, func(ma datamodel.MapAssembler) {
|
||||
qp.MapEntry(ma, "Content-Type", qp.String("application/json"))
|
||||
})
|
||||
if err != nil {
|
||||
errs = errors.Join(errs, fmt.Errorf("failed to build headers: %w", err))
|
||||
}
|
||||
|
||||
// ***** WARNING - do not change the order of these elements. DAG-CBOR
|
||||
// will order them alphabetically and the unsealed
|
||||
// result won't match if the input isn't also created in
|
||||
// alphabetical order.
|
||||
payload, err := qp.BuildMap(basicnode.Prototype.Any, 4, func(ma datamodel.MapAssembler) {
|
||||
qp.MapEntry(ma, "body", qp.String("UCAN is great"))
|
||||
qp.MapEntry(ma, "draft", qp.Bool(true))
|
||||
qp.MapEntry(ma, "title", qp.String("UCAN for Fun and Profit"))
|
||||
qp.MapEntry(ma, "topics", qp.List(2, func(la datamodel.ListAssembler) {
|
||||
qp.ListEntry(la, qp.String("authz"))
|
||||
qp.ListEntry(la, qp.String("journal"))
|
||||
}))
|
||||
})
|
||||
if err != nil {
|
||||
errs = errors.Join(errs, fmt.Errorf("failed to build payload: %w", err))
|
||||
}
|
||||
|
||||
args = map[string]datamodel.Node{
|
||||
"uri": basicnode.NewString("https://example.com/blog/posts"),
|
||||
"headers": headers,
|
||||
"payload": payload,
|
||||
}
|
||||
|
||||
prf = make([]cid.Cid, 3)
|
||||
for i, v := range []string{
|
||||
"zdpuAzx4sBrBCabrZZqXgvK3NDzh7Mf5mKbG11aBkkMCdLtCp",
|
||||
"zdpuApTCXfoKh2sB1KaUaVSGofCBNPUnXoBb6WiCeitXEibZy",
|
||||
"zdpuAoFdXRPw4n6TLcncoDhq1Mr6FGbpjAiEtqSBrTSaYMKkf",
|
||||
} {
|
||||
prf[i], err = cid.Parse(v)
|
||||
if err != nil {
|
||||
errs = errors.Join(errs, fmt.Errorf("failed to parse proof cid: %w", err))
|
||||
}
|
||||
}
|
||||
|
||||
// ***** WARNING - do not change the order of these elements. DAG-CBOR
|
||||
// will order them alphabetically and the unsealed
|
||||
// result won't match if the input isn't also created in
|
||||
// alphabetical order.
|
||||
tags, err := qp.BuildList(basicnode.Prototype.Any, 3, func(la datamodel.ListAssembler) {
|
||||
qp.ListEntry(la, qp.String("blog"))
|
||||
qp.ListEntry(la, qp.String("post"))
|
||||
qp.ListEntry(la, qp.String("pr#123"))
|
||||
})
|
||||
if err != nil {
|
||||
errs = errors.Join(errs, fmt.Errorf("failed to build tags: %w", err))
|
||||
}
|
||||
|
||||
meta = map[string]datamodel.Node{
|
||||
"env": basicnode.NewString("development"),
|
||||
"tags": tags,
|
||||
}
|
||||
|
||||
return // WARNING: named return values
|
||||
}
|
||||
@@ -13,32 +13,89 @@ import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/ipfs/go-cid"
|
||||
|
||||
"github.com/ucan-wg/go-ucan/did"
|
||||
"github.com/ucan-wg/go-ucan/pkg/args"
|
||||
"github.com/ucan-wg/go-ucan/pkg/command"
|
||||
"github.com/ucan-wg/go-ucan/pkg/meta"
|
||||
"github.com/ucan-wg/go-ucan/token/internal/parse"
|
||||
)
|
||||
|
||||
// Token is an immutable type that holds the fields of a UCAN invocation.
|
||||
type Token struct {
|
||||
// Issuer DID (invoker)
|
||||
// The DID of the Invoker
|
||||
issuer did.DID
|
||||
// Audience DID (receiver/executor)
|
||||
audience did.DID
|
||||
// Subject DID (subject being invoked)
|
||||
// The DID of Subject being invoked
|
||||
subject did.DID
|
||||
// The Command to invoke
|
||||
// The DID of the intended Executor if different from the Subject
|
||||
audience did.DID
|
||||
|
||||
// The Command
|
||||
command command.Command
|
||||
// TODO: args
|
||||
// TODO: prf
|
||||
// A unique, random nonce
|
||||
nonce []byte
|
||||
// The Command's Arguments
|
||||
arguments *args.Args
|
||||
// Delegations that prove the chain of authority
|
||||
proof []cid.Cid
|
||||
|
||||
// Arbitrary Metadata
|
||||
meta *meta.Meta
|
||||
|
||||
// A unique, random nonce
|
||||
nonce []byte
|
||||
// The timestamp at which the Invocation becomes invalid
|
||||
expiration *time.Time
|
||||
// The timestamp at which the Invocation was created
|
||||
invokedAt *time.Time
|
||||
// TODO: cause
|
||||
|
||||
// An optional CID of the Receipt that enqueued the Task
|
||||
cause *cid.Cid
|
||||
}
|
||||
|
||||
// New creates an invocation Token with the provided options.
|
||||
//
|
||||
// If no nonce is provided, a random 12-byte nonce is generated. Use the
|
||||
// WithNonce or WithEmptyNonce options to specify provide your own nonce
|
||||
// or to leave the nonce empty respectively.
|
||||
//
|
||||
// If no invokedAt is provided, the current time is used. Use the
|
||||
// WithInvokedAt or WithInvokedAtIn Options to specify a different time
|
||||
// or the WithoutInvokedAt Option to clear the Token's invokedAt field.
|
||||
//
|
||||
// With the exception of the WithMeta option, all others will overwrite
|
||||
// the previous contents of their target field.
|
||||
func New(iss, sub did.DID, cmd command.Command, prf []cid.Cid, opts ...Option) (*Token, error) {
|
||||
iat := time.Now()
|
||||
|
||||
tkn := Token{
|
||||
issuer: iss,
|
||||
subject: sub,
|
||||
command: cmd,
|
||||
arguments: args.New(),
|
||||
proof: prf,
|
||||
meta: meta.NewMeta(),
|
||||
nonce: nil,
|
||||
invokedAt: &iat,
|
||||
}
|
||||
|
||||
for _, opt := range opts {
|
||||
if err := opt(&tkn); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if len(tkn.nonce) == 0 {
|
||||
tkn.nonce, err = generateNonce()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if err := tkn.validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &tkn, nil
|
||||
}
|
||||
|
||||
// Issuer returns the did.DID representing the Token's issuer.
|
||||
@@ -46,28 +103,31 @@ func (t *Token) Issuer() did.DID {
|
||||
return t.issuer
|
||||
}
|
||||
|
||||
// Subject returns the did.DID representing the Token's subject.
|
||||
func (t *Token) Subject() did.DID {
|
||||
return t.subject
|
||||
}
|
||||
|
||||
// Audience returns the did.DID representing the Token's audience.
|
||||
func (t *Token) Audience() did.DID {
|
||||
return t.audience
|
||||
}
|
||||
|
||||
// Subject returns the did.DID representing the Token's subject.
|
||||
//
|
||||
// This field may be did.Undef for delegations that are [Powerlined] but
|
||||
// must be equal to the value returned by the Issuer method for root
|
||||
// tokens.
|
||||
func (t *Token) Subject() did.DID {
|
||||
return t.subject
|
||||
}
|
||||
|
||||
// Command returns the capability's command.Command.
|
||||
func (t *Token) Command() command.Command {
|
||||
return t.command
|
||||
}
|
||||
|
||||
// Nonce returns the random Nonce encapsulated in this Token.
|
||||
func (t *Token) Nonce() []byte {
|
||||
return t.nonce
|
||||
// Arguments returns the arguments to be used when the command is
|
||||
// invoked.
|
||||
func (t *Token) Arguments() *args.Args {
|
||||
return t.arguments
|
||||
}
|
||||
|
||||
// Proof() returns the ordered list of cid.Cid which reference the
|
||||
// delegation Tokens that authorize this invocation.
|
||||
func (t *Token) Proof() []cid.Cid {
|
||||
return t.proof
|
||||
}
|
||||
|
||||
// Meta returns the Token's metadata.
|
||||
@@ -75,11 +135,28 @@ func (t *Token) Meta() meta.ReadOnly {
|
||||
return t.meta.ReadOnly()
|
||||
}
|
||||
|
||||
// Nonce returns the random Nonce encapsulated in this Token.
|
||||
func (t *Token) Nonce() []byte {
|
||||
return t.nonce
|
||||
}
|
||||
|
||||
// Expiration returns the time at which the Token expires.
|
||||
func (t *Token) Expiration() *time.Time {
|
||||
return t.expiration
|
||||
}
|
||||
|
||||
// InvokedAt returns the time.Time at which the invocation token was
|
||||
// created.
|
||||
func (t *Token) InvokedAt() *time.Time {
|
||||
return t.invokedAt
|
||||
}
|
||||
|
||||
// Cause returns the Token's (optional) cause field which may specify
|
||||
// which describes the Receipt that requested the invocation.
|
||||
func (t *Token) Cause() *cid.Cid {
|
||||
return t.cause
|
||||
}
|
||||
|
||||
func (t *Token) validate() error {
|
||||
var errs error
|
||||
|
||||
@@ -90,8 +167,7 @@ func (t *Token) validate() error {
|
||||
}
|
||||
|
||||
requiredDID(t.issuer, "Issuer")
|
||||
|
||||
// TODO
|
||||
requiredDID(t.subject, "Subject")
|
||||
|
||||
if len(t.nonce) < 12 {
|
||||
errs = errors.Join(errs, fmt.Errorf("token nonce too small"))
|
||||
@@ -105,9 +181,42 @@ func (t *Token) validate() error {
|
||||
func tokenFromModel(m tokenPayloadModel) (*Token, error) {
|
||||
var (
|
||||
tkn Token
|
||||
err error
|
||||
)
|
||||
|
||||
// TODO
|
||||
if tkn.issuer, err = did.Parse(m.Iss); err != nil {
|
||||
return nil, fmt.Errorf("parse iss: %w", err)
|
||||
}
|
||||
|
||||
if tkn.subject, err = did.Parse(m.Sub); err != nil {
|
||||
return nil, fmt.Errorf("parse subject: %w", err)
|
||||
}
|
||||
|
||||
if tkn.audience, err = parse.OptionalDID(m.Aud); err != nil {
|
||||
return nil, fmt.Errorf("parse audience: %w", err)
|
||||
}
|
||||
|
||||
if tkn.command, err = command.Parse(m.Cmd); err != nil {
|
||||
return nil, fmt.Errorf("parse command: %w", err)
|
||||
}
|
||||
|
||||
if len(m.Nonce) == 0 {
|
||||
return nil, fmt.Errorf("nonce is required")
|
||||
}
|
||||
tkn.nonce = m.Nonce
|
||||
|
||||
tkn.arguments = m.Args
|
||||
tkn.proof = m.Prf
|
||||
tkn.meta = m.Meta
|
||||
|
||||
tkn.expiration = parse.OptionalTimestamp(m.Exp)
|
||||
tkn.invokedAt = parse.OptionalTimestamp(m.Iat)
|
||||
|
||||
tkn.cause = m.Cause
|
||||
|
||||
if err := tkn.validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &tkn, nil
|
||||
}
|
||||
|
||||
@@ -1,23 +1,32 @@
|
||||
|
||||
type DID string
|
||||
|
||||
# The Invocation Payload attaches sender, receiver, and provenance to the Task.
|
||||
type Payload struct {
|
||||
# Issuer DID (sender)
|
||||
# The DID of the invoker
|
||||
iss DID
|
||||
# Audience DID (receiver)
|
||||
aud DID
|
||||
# Principal that the chain is about (the Subject)
|
||||
sub optional DID
|
||||
# The Subject being invoked
|
||||
sub DID
|
||||
# The DID of the intended Executor if different from the Subject
|
||||
aud optional DID
|
||||
|
||||
# The Command to eventually invoke
|
||||
# The Command
|
||||
cmd String
|
||||
|
||||
# A unique, random nonce
|
||||
nonce Bytes
|
||||
# The Command's Arguments
|
||||
args { String : Any}
|
||||
# Delegations that prove the chain of authority
|
||||
prf [ Link ]
|
||||
|
||||
# Arbitrary Metadata
|
||||
meta {String : Any}
|
||||
meta optional { String : Any }
|
||||
|
||||
# A unique, random nonce
|
||||
nonce optional Bytes
|
||||
# The timestamp at which the Invocation becomes invalid
|
||||
exp nullable Int
|
||||
# The Timestamp at which the Invocation was created
|
||||
iat optional Int
|
||||
|
||||
# An optional CID of the Receipt that enqueued the Task
|
||||
cause optional Link
|
||||
}
|
||||
|
||||
@@ -193,29 +193,42 @@ func FromIPLD(node datamodel.Node) (*Token, error) {
|
||||
}
|
||||
|
||||
func (t *Token) toIPLD(privKey crypto.PrivKey) (datamodel.Node, error) {
|
||||
var sub *string
|
||||
var aud *string
|
||||
|
||||
if t.subject != did.Undef {
|
||||
s := t.subject.String()
|
||||
sub = &s
|
||||
if t.audience != did.Undef {
|
||||
a := t.audience.String()
|
||||
aud = &a
|
||||
}
|
||||
|
||||
// TODO
|
||||
|
||||
var exp *int64
|
||||
if t.expiration != nil {
|
||||
u := t.expiration.Unix()
|
||||
exp = &u
|
||||
}
|
||||
|
||||
var iat *int64
|
||||
if t.invokedAt != nil {
|
||||
i := t.invokedAt.Unix()
|
||||
iat = &i
|
||||
}
|
||||
|
||||
model := &tokenPayloadModel{
|
||||
Iss: t.issuer.String(),
|
||||
Aud: t.audience.String(),
|
||||
Sub: sub,
|
||||
Aud: aud,
|
||||
Sub: t.subject.String(),
|
||||
Cmd: t.command.String(),
|
||||
Args: t.arguments,
|
||||
Prf: t.proof,
|
||||
Meta: t.meta,
|
||||
Nonce: t.nonce,
|
||||
Meta: *t.meta,
|
||||
Exp: exp,
|
||||
Iat: iat,
|
||||
Cause: t.cause,
|
||||
}
|
||||
|
||||
// seems like it's a requirement to have a null meta if there are no values?
|
||||
if len(model.Meta.Keys) == 0 {
|
||||
model.Meta = nil
|
||||
}
|
||||
|
||||
return envelope.ToIPLD(privKey, model)
|
||||
|
||||
38
token/invocation/ipld_test.go
Normal file
38
token/invocation/ipld_test.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package invocation_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/ucan-wg/go-ucan/token/invocation"
|
||||
)
|
||||
|
||||
func TestSealUnsealRoundtrip(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
privKey, iss, sub, cmd, args, prf, meta, err := setupExampleNew()
|
||||
require.NoError(t, err)
|
||||
|
||||
tkn1, err := invocation.New(iss, sub, cmd, prf,
|
||||
invocation.WithArgument("uri", args["uri"]),
|
||||
invocation.WithArgument("headers", args["headers"]),
|
||||
invocation.WithArgument("payload", args["payload"]),
|
||||
invocation.WithMeta("env", "development"),
|
||||
invocation.WithMeta("tags", meta["tags"]),
|
||||
invocation.WithExpirationIn(time.Minute),
|
||||
invocation.WithoutInvokedAt(),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
data, cid1, err := tkn1.ToSealed(privKey)
|
||||
require.NoError(t, err)
|
||||
|
||||
tkn2, cid2, err := invocation.FromSealed(data)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, cid1, cid2)
|
||||
assert.Equal(t, tkn1, tkn2)
|
||||
}
|
||||
125
token/invocation/options.go
Normal file
125
token/invocation/options.go
Normal file
@@ -0,0 +1,125 @@
|
||||
package invocation
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/ipfs/go-cid"
|
||||
|
||||
"github.com/ucan-wg/go-ucan/did"
|
||||
)
|
||||
|
||||
// Option is a type that allows optional fields to be set during the
|
||||
// creation of an invocation Token.
|
||||
type Option func(*Token) error
|
||||
|
||||
// WithArgument adds a key/value pair to the Token's Arguments field.
|
||||
func WithArgument(key string, val any) Option {
|
||||
return func(t *Token) error {
|
||||
return t.arguments.Add(key, val)
|
||||
}
|
||||
}
|
||||
|
||||
// WithAudience sets the Token's audience to the provided did.DID.
|
||||
//
|
||||
// If the provided did.DID is the same as the Token's subject, the
|
||||
// audience is not set.
|
||||
func WithAudience(aud did.DID) Option {
|
||||
return func(t *Token) error {
|
||||
if t.subject.String() != aud.String() {
|
||||
t.audience = aud
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// WithMeta adds a key/value pair in the "meta" field.
|
||||
//
|
||||
// WithMeta can be used multiple times in the same call.
|
||||
// Accepted types for the value are: bool, string, int, int32, int64, []byte,
|
||||
// and ipld.Node.
|
||||
func WithMeta(key string, val any) Option {
|
||||
return func(t *Token) error {
|
||||
return t.meta.Add(key, val)
|
||||
}
|
||||
}
|
||||
|
||||
// WithNonce sets the Token's nonce with the given value.
|
||||
//
|
||||
// If this option is not used, a random 12-byte nonce is generated for
|
||||
// this required field. If you truly want to create an invocation Token
|
||||
// without a nonce, use the WithEmptyNonce Option which will set the
|
||||
// nonce to an empty byte array.
|
||||
func WithNonce(nonce []byte) Option {
|
||||
return func(t *Token) error {
|
||||
t.nonce = nonce
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// WithEmptyNonce sets the Token's nonce to an empty byte slice as
|
||||
// suggested by the UCAN spec for invocation tokens that represent
|
||||
// idempotent operations.
|
||||
func WithEmptyNonce() Option {
|
||||
return func(t *Token) error {
|
||||
t.nonce = []byte{}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// WithExpiration set's the Token's optional "expiration" field to the
|
||||
// value of the provided time.Time.
|
||||
func WithExpiration(exp time.Time) Option {
|
||||
return func(t *Token) error {
|
||||
exp = exp.Round(time.Second)
|
||||
t.expiration = &exp
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// WithExpirationIn set's the Token's optional "expiration" field to
|
||||
// Now() plus the given duration.
|
||||
func WithExpirationIn(after time.Duration) Option {
|
||||
return WithExpiration(time.Now().Add(after))
|
||||
}
|
||||
|
||||
// WithInvokedAt sets the Token's invokedAt field to the provided
|
||||
// time.Time.
|
||||
//
|
||||
// If this Option is not provided, the invocation Token's iat field will
|
||||
// be set to the value of time.Now(). If you want to create an invocation
|
||||
// Token without this field being set, use the WithoutInvokedAt Option.
|
||||
func WithInvokedAt(iat time.Time) Option {
|
||||
return func(t *Token) error {
|
||||
t.invokedAt = &iat
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// WithInvokedAtIn sets the Token's invokedAt field to Now() plus the
|
||||
// given duration.
|
||||
func WithInvokedAtIn(after time.Duration) Option {
|
||||
return WithInvokedAt(time.Now().Add(after))
|
||||
}
|
||||
|
||||
// WithoutInvokedAt clears the Token's invokedAt field.
|
||||
func WithoutInvokedAt() Option {
|
||||
return func(t *Token) error {
|
||||
t.invokedAt = nil
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// WithCause sets the Token's cause field to the provided cid.Cid.
|
||||
func WithCause(cause *cid.Cid) Option {
|
||||
return func(t *Token) error {
|
||||
t.cause = cause
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -5,10 +5,12 @@ import (
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"github.com/ipfs/go-cid"
|
||||
"github.com/ipld/go-ipld-prime"
|
||||
"github.com/ipld/go-ipld-prime/node/bindnode"
|
||||
"github.com/ipld/go-ipld-prime/schema"
|
||||
|
||||
"github.com/ucan-wg/go-ucan/pkg/args"
|
||||
"github.com/ucan-wg/go-ucan/pkg/meta"
|
||||
"github.com/ucan-wg/go-ucan/token/internal/envelope"
|
||||
)
|
||||
@@ -44,28 +46,34 @@ func payloadType() schema.Type {
|
||||
|
||||
var _ envelope.Tokener = (*tokenPayloadModel)(nil)
|
||||
|
||||
// TODO
|
||||
type tokenPayloadModel struct {
|
||||
// Issuer DID (sender)
|
||||
// The DID of the Invoker
|
||||
Iss string
|
||||
// Audience DID (receiver)
|
||||
Aud string
|
||||
// Principal that the chain is about (the Subject)
|
||||
// optional: can be nil
|
||||
Sub *string
|
||||
// The DID of Subject being invoked
|
||||
Sub string
|
||||
// The DID of the intended Executor if different from the Subject
|
||||
Aud *string
|
||||
|
||||
// The Command to eventually invoke
|
||||
// The Command
|
||||
Cmd string
|
||||
// The Command's Arguments
|
||||
Args *args.Args
|
||||
// Delegations that prove the chain of authority
|
||||
Prf []cid.Cid
|
||||
|
||||
// Arbitrary Metadata
|
||||
Meta *meta.Meta
|
||||
|
||||
// A unique, random nonce
|
||||
Nonce []byte
|
||||
|
||||
// Arbitrary Metadata
|
||||
Meta meta.Meta
|
||||
|
||||
// The timestamp at which the Invocation becomes invalid
|
||||
// optional: can be nil
|
||||
Exp *int64
|
||||
// The timestamp at which the Invocation was created
|
||||
Iat *int64
|
||||
|
||||
// An optional CID of the Receipt that enqueued the Task
|
||||
Cause *cid.Cid
|
||||
}
|
||||
|
||||
func (e *tokenPayloadModel) Prototype() schema.TypedPrototype {
|
||||
|
||||
91
token/invocation/schema_test.go
Normal file
91
token/invocation/schema_test.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package invocation_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/libp2p/go-libp2p/core/crypto"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gotest.tools/v3/golden"
|
||||
|
||||
"github.com/ucan-wg/go-ucan/token/internal/envelope"
|
||||
"github.com/ucan-wg/go-ucan/token/invocation"
|
||||
)
|
||||
|
||||
const (
|
||||
issuerPrivKeyCfg = "CAESQK45xBfqIxRp7ZdRdck3tIJZKocCqvANQc925dCJhFwO7DJNA2j94zkF0TNx5mpXV0s6utfkFdHddWTaPVU6yZc="
|
||||
newCID = "zdpuAqY6Zypg4UnpbSUgDvYGneyFaTKaZevzxgSxV4rmv3Fpp"
|
||||
)
|
||||
|
||||
func TestSchemaRoundTrip(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
invocationJson := golden.Get(t, "new.dagjson")
|
||||
privKey := privKey(t, issuerPrivKeyCfg)
|
||||
|
||||
t.Run("via buffers", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// format: dagJson --> PayloadModel --> dagCbor --> PayloadModel --> dagJson
|
||||
// function: DecodeDagJson() Seal() Unseal() EncodeDagJson()
|
||||
|
||||
p1, err := invocation.FromDagJson(invocationJson)
|
||||
require.NoError(t, err)
|
||||
|
||||
cborBytes, id, err := p1.ToSealed(privKey)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, newCID, envelope.CIDToBase58BTC(id))
|
||||
fmt.Println("cborBytes length", len(cborBytes))
|
||||
fmt.Println("cbor", string(cborBytes))
|
||||
|
||||
p2, c2, err := invocation.FromSealed(cborBytes)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, id, c2)
|
||||
fmt.Println("read Cbor", p2)
|
||||
|
||||
readJson, err := p2.ToDagJson(privKey)
|
||||
require.NoError(t, err)
|
||||
fmt.Println("readJson length", len(readJson))
|
||||
fmt.Println("json: ", string(readJson))
|
||||
|
||||
assert.JSONEq(t, string(invocationJson), string(readJson))
|
||||
})
|
||||
|
||||
t.Run("via streaming", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
buf := bytes.NewBuffer(invocationJson)
|
||||
|
||||
// format: dagJson --> PayloadModel --> dagCbor --> PayloadModel --> dagJson
|
||||
// function: DecodeDagJson() Seal() Unseal() EncodeDagJson()
|
||||
|
||||
p1, err := invocation.FromDagJsonReader(buf)
|
||||
require.NoError(t, err)
|
||||
|
||||
cborBytes := &bytes.Buffer{}
|
||||
id, err := p1.ToSealedWriter(cborBytes, privKey)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, newCID, envelope.CIDToBase58BTC(id))
|
||||
|
||||
p2, c2, err := invocation.FromSealedReader(cborBytes)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, envelope.CIDToBase58BTC(id), envelope.CIDToBase58BTC(c2))
|
||||
|
||||
readJson := &bytes.Buffer{}
|
||||
require.NoError(t, p2.ToDagJsonWriter(readJson, privKey))
|
||||
|
||||
assert.JSONEq(t, string(invocationJson), readJson.String())
|
||||
})
|
||||
}
|
||||
|
||||
func privKey(t require.TestingT, privKeyCfg string) crypto.PrivKey {
|
||||
privKeyMar, err := crypto.ConfigDecodeKey(privKeyCfg)
|
||||
require.NoError(t, err)
|
||||
|
||||
privKey, err := crypto.UnmarshalPrivateKey(privKeyMar)
|
||||
require.NoError(t, err)
|
||||
|
||||
return privKey
|
||||
}
|
||||
1
token/invocation/testdata/new.dagjson
vendored
Normal file
1
token/invocation/testdata/new.dagjson
vendored
Normal file
@@ -0,0 +1 @@
|
||||
[{"/":{"bytes":"o/vTvTs8SEkD9QL/eNhhW0fAng/SGBouywCbUnOfsF2RFHxaV02KTCyzgDxlJLZ2XN/Vk5igLmlKL3QIXMaeCQ"}},{"h":{"/":{"bytes":"NO0BcQ"}},"ucan/inv@1.0.0-rc.1":{"args":{"headers":{"Content-Type":"application/json"},"payload":{"body":"UCAN is great","draft":true,"title":"UCAN for Fun and Profit","topics":["authz","journal"]},"uri":"https://example.com/blog/posts"},"cmd":"/crud/create","exp":1730812145,"iss":"did:key:z6MkvMGkN5nbUQLBVqJhr13Zdqyh9rR1VuF16PuZbfocBxpv","meta":{"env":"development","tags":["blog","post","pr#123"]},"nonce":{"/":{"bytes":"q1AH6MJrqoTH6av7"}},"prf":[{"/":"bafyreigx3qxd2cndpe66j2mdssj773ecv7tqd7wovcnz5raguw6lj7sjoe"},{"/":"bafyreib34ira254zdqgehz6f2bhwme2ja2re3ltcalejv4x4tkcveujvpa"},{"/":"bafyreibkb66tpo2ixqx3fe5hmekkbuasrod6olt5bwm5u5pi726mduuwlq"}],"sub":"did:key:z6MkuFj35aiTL7YQiVMobuSeUQju92g7wZzufS3HAc6NFFcQ"}}]
|
||||
Reference in New Issue
Block a user