diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b0024d..1f86c0f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/). ## [Unreleased] +### Changed + +- **BREAKING (TAIP-9):** Reshaped `ConfirmRelationshipBody` to match the TAIP-9 + spec, whose confirmation payload is an `Agent` payload. The body is now flat: + `@context`, `@type` (set to `https://tap.rsvp/schema/1.0#Agent`), `@id` (the + DID of the agent being confirmed, REQUIRED), `for` (the DID of the entity the + agent acts for, REQUIRED), and `role` (OPTIONAL). The non-spec + `relationship` (`Relationship *Relationship`), `status`, `validFrom`, + `validUntil`, and `details` fields were removed. `NewConfirmRelationshipMessage` + now validates `@id` and `for` instead of `relationship` and `status`. Consumers + must read `body.ID` for the confirmed address and `body.For` for the owner + instead of `body.Relationship.Parties` / `body.Status`. Added the `TypeAgent` + constant for the body's JSON-LD `@type`. + +### Removed + +- The `Relationship` struct (`types.go`), which was used only by the now-reshaped + `ConfirmRelationshipBody`. + ## [0.4.0] - 2026-05-05 ### Changed diff --git a/README.md b/README.md index 0bfc186..b4e45cd 100644 --- a/README.md +++ b/README.md @@ -189,7 +189,7 @@ envelope, _ := dc.PackAuthcrypt(ctx, authorizeMsg) | Constructor | Body Struct | TAIP | Required Fields | |------------|-------------|------|-----------------| -| `NewConfirmRelationshipMessage` | `ConfirmRelationshipBody` | [TAIP-9](https://tap.rsvp/TAIPs/taip-9) | `relationship`, `status` | +| `NewConfirmRelationshipMessage` | `ConfirmRelationshipBody` | [TAIP-9](https://tap.rsvp/TAIPs/taip-9) | `@id`, `for` | | `NewUpdatePoliciesMessage` | `UpdatePoliciesBody` | [TAIP-7](https://tap.rsvp/TAIPs/taip-7) | `policies` | | `NewConnectMessage` | `ConnectBody` | [TAIP-15](https://tap.rsvp/TAIPs/taip-15) | `requester`, `principal`, `agents`, `constraints` | diff --git a/cmd/tap/message_test.go b/cmd/tap/message_test.go index 7534f5e..b84fc4d 100644 --- a/cmd/tap/message_test.go +++ b/cmd/tap/message_test.go @@ -643,7 +643,7 @@ func TestCLI_MessageUpdatePolicies(t *testing.T) { func TestCLI_MessageConfirmRelationship(t *testing.T) { bin := buildBinary(t) - body := `{"relationship":{"type":"customer","parties":[{"@id":"did:key:z1"},{"@id":"did:key:z2"}]},"status":"confirmed"}` + body := `{"@id":"did:pkh:eip155:1:0x1234","for":"did:key:z1","role":"SettlementAddress"}` cmd := exec.Command(bin, "message", "confirm-relationship", "--from", "did:key:z1", diff --git a/confirm_relationship.go b/confirm_relationship.go index 702d47f..f7c9c4b 100644 --- a/confirm_relationship.go +++ b/confirm_relationship.go @@ -8,30 +8,29 @@ import ( "github.com/google/uuid" ) -// ConfirmRelationshipBody represents the body of a TAP ConfirmRelationship message (TAIP-9). +// ConfirmRelationshipBody is the body of a ConfirmRelationship message (TAIP-9): +// the Agent payload, asserting that @id acts for the entity in "for". type ConfirmRelationshipBody struct { - Context string `json:"@context"` - Type string `json:"@type"` - Relationship *Relationship `json:"relationship"` - Status string `json:"status"` - ValidFrom string `json:"validFrom,omitempty"` - ValidUntil string `json:"validUntil,omitempty"` - Details any `json:"details,omitempty"` + Context string `json:"@context"` + Type string `json:"@type"` + ID string `json:"@id"` // the agent being confirmed + For ForField `json:"for"` // the entity it acts on behalf of + Role string `json:"role,omitempty"` } func (b *ConfirmRelationshipBody) TAPType() string { return TypeConfirmRelationship } // NewConfirmRelationshipMessage creates a new DIDComm message with a ConfirmRelationship body. func NewConfirmRelationshipMessage(from string, to []string, thid string, body *ConfirmRelationshipBody) (*didcomm.Message, error) { - if body.Relationship == nil { - return nil, fmt.Errorf("%w: missing relationship", ErrInvalidBody) + if body.ID == "" { + return nil, fmt.Errorf("%w: missing @id", ErrInvalidBody) } - if body.Status == "" { - return nil, fmt.Errorf("%w: missing status", ErrInvalidBody) + if body.For.IsEmpty() { + return nil, fmt.Errorf("%w: missing for", ErrInvalidBody) } body.Context = TAPContext - body.Type = TypeConfirmRelationship + body.Type = TypeAgent rawBody, err := json.Marshal(body) if err != nil { diff --git a/confirm_relationship_test.go b/confirm_relationship_test.go index bafeab8..fc2e7a9 100644 --- a/confirm_relationship_test.go +++ b/confirm_relationship_test.go @@ -3,19 +3,15 @@ package tap import ( "encoding/json" "errors" + "slices" "testing" ) func TestNewConfirmRelationshipMessage(t *testing.T) { body := &ConfirmRelationshipBody{ - Relationship: &Relationship{ - Type: "customer", - Parties: []Party{ - {ID: "did:eg:alice"}, - {ID: "did:eg:bob"}, - }, - }, - Status: "confirmed", + ID: "did:pkh:eip155:1:0x1234a96D359eC26a11e2C2b3d8f8B8942d5Bfcdb", + For: NewForField("did:web:beneficiary.vasp"), + Role: "SettlementAddress", } msg, err := NewConfirmRelationshipMessage("did:web:beneficiary.vasp", []string{"did:web:originator.vasp"}, "thread-1", body) if err != nil { @@ -24,6 +20,9 @@ func TestNewConfirmRelationshipMessage(t *testing.T) { if msg.Type != TypeConfirmRelationship { t.Errorf("Type: got %q", msg.Type) } + if body.Type != TypeAgent { + t.Errorf("body @type: got %q, want %q", body.Type, TypeAgent) + } } func TestNewConfirmRelationshipMessage_MissingFields(t *testing.T) { @@ -31,8 +30,8 @@ func TestNewConfirmRelationshipMessage_MissingFields(t *testing.T) { name string body *ConfirmRelationshipBody }{ - {"missing relationship", &ConfirmRelationshipBody{Status: "confirmed"}}, - {"missing status", &ConfirmRelationshipBody{Relationship: &Relationship{Type: "customer", Parties: []Party{{ID: "a"}, {ID: "b"}}}}}, + {"missing @id", &ConfirmRelationshipBody{For: NewForField("did:web:beneficiary.vasp")}}, + {"missing for", &ConfirmRelationshipBody{ID: "did:pkh:eip155:1:0x1234"}}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -44,36 +43,121 @@ func TestNewConfirmRelationshipMessage_MissingFields(t *testing.T) { } } -func TestConfirmRelationshipBody_JSONRoundTrip(t *testing.T) { - body := ConfirmRelationshipBody{ - Context: TAPContext, - Type: TypeConfirmRelationship, - Relationship: &Relationship{ - Type: "customer", - Parties: []Party{{ID: "did:eg:alice"}, {ID: "did:eg:bob"}}, - }, - Status: "confirmed", - ValidFrom: "2024-01-01T00:00:00Z", +// TestConfirmRelationshipBody_SpecCanonical verifies the spec's canonical +// settlement-address example (TAIP-9 test case 1) round-trips with the exact +// JSON field names and casing. +func TestConfirmRelationshipBody_SpecCanonical(t *testing.T) { + raw := []byte(`{ + "@context":"https://tap.rsvp/schema/1.0", + "@type":"https://tap.rsvp/schema/1.0#Agent", + "@id":"did:pkh:eip155:1:0x1234a96D359eC26a11e2C2b3d8f8B8942d5Bfcdb", + "for":"did:web:beneficiary.vasp", + "role":"SettlementAddress" + }`) + + var got ConfirmRelationshipBody + if err := json.Unmarshal(raw, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if got.ID != "did:pkh:eip155:1:0x1234a96D359eC26a11e2C2b3d8f8B8942d5Bfcdb" { + t.Errorf("@id: got %q", got.ID) + } + if got.For.String() != "did:web:beneficiary.vasp" { + t.Errorf("for: got %q", got.For.String()) + } + if got.Role != "SettlementAddress" { + t.Errorf("role: got %q", got.Role) + } + if got.Type != TypeAgent { + t.Errorf("@type: got %q, want %q", got.Type, TypeAgent) } - data, err := json.Marshal(body) + // Re-marshal and confirm the spec JSON field names are present. + data, err := json.Marshal(got) if err != nil { t.Fatalf("marshal: %v", err) } + var fields map[string]json.RawMessage + if err := json.Unmarshal(data, &fields); err != nil { + t.Fatalf("unmarshal fields: %v", err) + } + for _, key := range []string{"@context", "@type", "@id", "for", "role"} { + if _, ok := fields[key]; !ok { + t.Errorf("marshalled body missing %q field; got keys %v", key, keysOf(fields)) + } + } +} + +// TestConfirmRelationshipBody_SpecCanonicalNoRole verifies the spec's second +// test case (VASP confirming it acts for its customer), where role is omitted. +func TestConfirmRelationshipBody_SpecCanonicalNoRole(t *testing.T) { + raw := []byte(`{ + "@context":"https://tap.rsvp/schema/1.0", + "@type":"https://tap.rsvp/schema/1.0#Agent", + "@id":"did:web:beneficiary.vasp", + "for":"did:eg:bob" + }`) var got ConfirmRelationshipBody - if err := json.Unmarshal(data, &got); err != nil { + if err := json.Unmarshal(raw, &got); err != nil { t.Fatalf("unmarshal: %v", err) } - if got.Status != "confirmed" || got.Relationship.Type != "customer" { + if got.ID != "did:web:beneficiary.vasp" || got.For.String() != "did:eg:bob" { t.Errorf("mismatch: %+v", got) } + + data, err := json.Marshal(got) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(data, &fields); err != nil { + t.Fatalf("unmarshal fields: %v", err) + } + if _, ok := fields["role"]; ok { + t.Errorf("role should be omitted when empty; got keys %v", keysOf(fields)) + } +} + +func TestConfirmRelationshipBody_ForRoundTrip(t *testing.T) { + tests := []struct { + name string + for_ ForField + want []string + }{ + {"single owner", NewForField("did:web:beneficiary.vasp"), []string{"did:web:beneficiary.vasp"}}, + {"multiple owners", NewForField("did:eg:alice", "did:eg:bob"), []string{"did:eg:alice", "did:eg:bob"}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + body := ConfirmRelationshipBody{ + Context: TAPContext, + Type: TypeAgent, + ID: "did:pkh:eip155:1:0x1234", + For: tt.for_, + } + + data, err := json.Marshal(body) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + var got ConfirmRelationshipBody + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if gotVals := got.For.Values(); !slices.Equal(gotVals, tt.want) { + t.Errorf("For: got %v, want %v", gotVals, tt.want) + } + }) + } } func TestConfirmRelationshipBody_ParseBody(t *testing.T) { body := &ConfirmRelationshipBody{ - Relationship: &Relationship{Type: "customer", Parties: []Party{{ID: "a"}, {ID: "b"}}}, - Status: "pending", + ID: "did:pkh:eip155:1:0x1234", + For: NewForField("did:web:beneficiary.vasp"), + Role: "SettlementAddress", } msg, err := NewConfirmRelationshipMessage("from", []string{"to"}, "thid", body) if err != nil { @@ -88,7 +172,21 @@ func TestConfirmRelationshipBody_ParseBody(t *testing.T) { if !ok { t.Fatalf("expected *ConfirmRelationshipBody, got %T", parsed) } - if crb.Status != "pending" { - t.Errorf("Status: got %q", crb.Status) + if crb.ID != "did:pkh:eip155:1:0x1234" { + t.Errorf("@id: got %q", crb.ID) + } + if crb.For.String() != "did:web:beneficiary.vasp" { + t.Errorf("for: got %q", crb.For.String()) + } + if crb.Role != "SettlementAddress" { + t.Errorf("role: got %q", crb.Role) + } +} + +func keysOf(m map[string]json.RawMessage) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) } + return out } diff --git a/message_types.go b/message_types.go index c0211bf..5008235 100644 --- a/message_types.go +++ b/message_types.go @@ -4,6 +4,10 @@ const ( // TAPContext is the JSON-LD context for all TAP messages. TAPContext = "https://tap.rsvp/schema/1.0" + // TypeAgent is the JSON-LD type for an Agent payload (TAIP-5). It is not a + // message type; it is the body @type of a ConfirmRelationship message. + TypeAgent = "https://tap.rsvp/schema/1.0#Agent" + // Transaction message types TypeTransfer = "https://tap.rsvp/schema/1.0#Transfer" TypePayment = "https://tap.rsvp/schema/1.0#Payment" diff --git a/parse_test.go b/parse_test.go index 358ff85..f9bf9e8 100644 --- a/parse_test.go +++ b/parse_test.go @@ -68,7 +68,7 @@ func TestParseBody_AllTypes(t *testing.T) { {"AddAgents", TypeAddAgents, &AddAgentsBody{Context: TAPContext, Type: TypeAddAgents, Agents: []Agent{{ID: "a"}}}}, {"ReplaceAgent", TypeReplaceAgent, &ReplaceAgentBody{Context: TAPContext, Type: TypeReplaceAgent, Original: "old", Replacement: &Agent{ID: "new"}}}, {"RemoveAgent", TypeRemoveAgent, &RemoveAgentBody{Context: TAPContext, Type: TypeRemoveAgent, Agent: "did:web:old"}}, - {"ConfirmRelationship", TypeConfirmRelationship, &ConfirmRelationshipBody{Context: TAPContext, Type: TypeConfirmRelationship, Relationship: &Relationship{Type: "customer", Parties: []Party{{ID: "a"}, {ID: "b"}}}, Status: "confirmed"}}, + {"ConfirmRelationship", TypeConfirmRelationship, &ConfirmRelationshipBody{Context: TAPContext, Type: TypeAgent, ID: "did:pkh:eip155:1:0x1234", For: NewForField("did:web:beneficiary.vasp"), Role: "SettlementAddress"}}, {"UpdatePolicies", TypeUpdatePolicies, &UpdatePoliciesBody{Context: TAPContext, Type: TypeUpdatePolicies, Policies: []Policy{{Type: "RequireAuthorization"}}}}, {"Connect", TypeConnect, &ConnectBody{Context: TAPContext, Type: TypeConnect, Requester: &Party{ID: "r"}, Principal: &Party{ID: "p"}, Agents: []Agent{{ID: "a"}}, Constraints: &TransactionConstraints{}}}, } diff --git a/types.go b/types.go index 71998d7..2b802f4 100644 --- a/types.go +++ b/types.go @@ -193,14 +193,6 @@ type DocumentReference struct { URL string `json:"url,omitempty"` } -// Relationship represents a relationship between parties for ConfirmRelationship. -type Relationship struct { - Type string `json:"type"` - Parties []Party `json:"parties"` - EstablishedDate string `json:"establishedDate,omitempty"` - Reference string `json:"reference,omitempty"` -} - // SupportedAssetPricing represents an asset with pricing information for payments. type SupportedAssetPricing struct { Asset string `json:"asset"` diff --git a/types_test.go b/types_test.go index 698d019..f292a04 100644 --- a/types_test.go +++ b/types_test.go @@ -224,27 +224,3 @@ func TestInvoice_JSONRoundTrip(t *testing.T) { t.Errorf("LineItems mismatch: got %v", got.LineItems) } } - -func TestRelationship_JSONRoundTrip(t *testing.T) { - rel := Relationship{ - Type: "customer", - Parties: []Party{ - {ID: "did:eg:alice"}, - {ID: "did:eg:bob"}, - }, - } - - data, err := json.Marshal(rel) - if err != nil { - t.Fatalf("marshal: %v", err) - } - - var got Relationship - if err := json.Unmarshal(data, &got); err != nil { - t.Fatalf("unmarshal: %v", err) - } - - if got.Type != "customer" || len(got.Parties) != 2 { - t.Errorf("round-trip mismatch: got %+v", got) - } -}