operator-channel: the state asks typed to the router's projection; mesh-node-enroller: redeem's answer typed, with a fixture and a client test (ADR 0634 §6; issue 1966)

Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
This commit is contained in:
2026-11-05 14:53:24 +01:00
parent ce9d39d3f0
commit 35aa61f4d9
9 changed files with 196 additions and 6 deletions
@@ -1,8 +1,13 @@
package meshnodeenrollerclient
import (
"bytes"
"encoding/json"
"reflect"
"testing"
"time"
seat "git.novox.be/novox/mesh-contracts/identity-and-access/go/seats/meshnodeenroller"
)
func TestATokenLifeAndTheRedeemProofAreAsTheHolderMakesThem(t *testing.T) {
@@ -28,3 +33,26 @@ func TestATokenLifeAndTheRedeemProofAreAsTheHolderMakesThem(t *testing.T) {
t.Errorf("SignedBytes = %s", got)
}
}
// TestTheRedeemAnswerIsTheContractsWire: the client's answer reads into the contract's type with no field dropped, and
// the contract's reads back as the client's wire.
func TestTheRedeemAnswerIsTheContractsWire(t *testing.T) {
mine := RedeemAnswer{Node: "g14", Login: "g14-login", Account: "YQ==", Certificate: "pem", Signer: "dg==", Signature: "cw=="}
wire, _ := json.Marshal(mine)
var theirs seat.RedeemAnswer
d := json.NewDecoder(bytes.NewReader(wire))
d.DisallowUnknownFields()
if err := d.Decode(&theirs); err != nil {
t.Fatal(err)
}
if theirs.Node != "g14" || theirs.Signer != "dg==" || theirs.Signature != "cw==" {
t.Errorf("the contract reads %+v", theirs)
}
back, _ := json.Marshal(theirs)
var a, b map[string]string
_ = json.Unmarshal(wire, &a)
_ = json.Unmarshal(back, &b)
if len(a) != 6 || !reflect.DeepEqual(a, b) {
t.Errorf("the contract reads %s back as %s", wire, back)
}
}
@@ -187,7 +187,20 @@ type RedeemInput struct {
}
// RedeemAnswer is what redeem answers.
type RedeemAnswer = map[string]any
type RedeemAnswer struct {
// its account sealed to the sealing key it presented, base64
Account string `json:"account"`
// its certificate, PEM
Certificate string `json:"certificate"`
// the login it dials as next
Login string `json:"login"`
// the node
Node string `json:"node"`
// the vault's signature over the answer without it, under the context of the enrolment kind, base64
Signature string `json:"signature,omitempty"`
// the vault's public signing half, base64
Signer string `json:"signer"`
}
// ConfirmInput is what confirm takes.
type ConfirmInput struct {
@@ -31,6 +31,8 @@ func TestFixtures(t *testing.T) {
err := json.Unmarshal(b, &v)
return v, err
}, subjectIs: nil},
{name: "redeem answers the account sealed to the node, signed by the vault", subject: "", given: "{\"account\":\"c2VhbGVk\",\"certificate\":\"-----BEGIN CERTIFICATE-----\\nMIIB\\n-----END CERTIFICATE-----\\n\",\"login\":\"g14-login\",\"node\":\"g14\",\"signature\":\"c2ln\",\"signer\":\"dmF1bHQ=\"}", wire: "{\"account\":\"c2VhbGVk\",\"certificate\":\"-----BEGIN CERTIFICATE-----\\nMIIB\\n-----END CERTIFICATE-----\\n\",\"login\":\"g14-login\",\"node\":\"g14\",\"signature\":\"c2ln\",\"signer\":\"dmF1bHQ=\"}",
decode: func(b []byte) (any, error) { var v RedeemAnswer; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil},
}
for _, c := range cases {
v, err := c.decode([]byte(c.given))
@@ -0,0 +1,14 @@
[
{
"name": "redeem answers the account sealed to the node, signed by the vault",
"of": "verb:redeem.answer",
"given": {
"node": "g14",
"login": "g14-login",
"account": "c2VhbGVk",
"certificate": "-----BEGIN CERTIFICATE-----\nMIIB\n-----END CERTIFICATE-----\n",
"signer": "dmF1bHQ=",
"signature": "c2ln"
}
}
]
@@ -90,7 +90,40 @@
},
"answer": {
"type": "object",
"description": "the account sealed to the node, its certificate and the vault's public half, signed by the vault"
"description": "the account sealed to the node, its certificate and the vault's public half, signed by the vault",
"properties": {
"node": {
"type": "string",
"description": "the node"
},
"login": {
"type": "string",
"description": "the login it dials as next"
},
"account": {
"type": "string",
"description": "its account sealed to the sealing key it presented, base64"
},
"certificate": {
"type": "string",
"description": "its certificate, PEM"
},
"signer": {
"type": "string",
"description": "the vault's public signing half, base64"
},
"signature": {
"type": "string",
"description": "the vault's signature over the answer without it, under the context of the enrolment kind, base64"
}
},
"required": [
"node",
"login",
"account",
"certificate",
"signer"
]
}
},
{
@@ -123,7 +123,20 @@ export interface RedeemInput {
}
/** is what redeem answers. */
export type RedeemAnswer = Record<string, unknown>;
export interface RedeemAnswer {
/** its account sealed to the sealing key it presented, base64 */
"account": string;
/** its certificate, PEM */
"certificate": string;
/** the login it dials as next */
"login": string;
/** the node */
"node": string;
/** the vault's signature over the answer without it, under the context of the enrolment kind, base64 */
"signature"?: string;
/** the vault's public signing half, base64 */
"signer": string;
}
/** is what confirm takes. */
export interface ConfirmInput {
@@ -477,4 +477,23 @@ const (
)
// AsksByAskerIDValue is one value of the state asks under the key <asker>.<id>.
type AsksByAskerIDValue = map[string]any
type AsksByAskerIDValue struct {
// how it ended, in words
Answer string `json:"answer,omitempty"`
// its asker
Asker string `json:"asker"`
// its asker has been told how it ended
AskerTold bool `json:"asker_told,omitempty"`
// when it ends unanswered, RFC 3339
Expires string `json:"expires"`
// its headline
Headline string `json:"headline"`
// <asker>.<id>
Key string `json:"key"`
// the channels it was offered on
OfferedOn []string `json:"offered_on,omitempty"`
// when it was opened, RFC 3339
Opened string `json:"opened"`
// how it stands: open, chosen, expired, cancelled, replaced or refused
State string `json:"state"`
}
@@ -182,7 +182,56 @@
"key": "<asker>.<id>",
"value": {
"type": "object",
"description": "one ask the router holds and how it stands"
"description": "one ask the router holds and how it stands, as the router's tool states it",
"properties": {
"key": {
"type": "string",
"description": "<asker>.<id>"
},
"asker": {
"type": "string",
"description": "its asker"
},
"headline": {
"type": "string",
"description": "its headline"
},
"state": {
"type": "string",
"description": "how it stands: open, chosen, expired, cancelled, replaced or refused"
},
"opened": {
"type": "string",
"description": "when it was opened, RFC 3339"
},
"expires": {
"type": "string",
"description": "when it ends unanswered, RFC 3339"
},
"offered_on": {
"type": "array",
"items": {
"type": "string"
},
"description": "the channels it was offered on"
},
"asker_told": {
"type": "boolean",
"description": "its asker has been told how it ended"
},
"answer": {
"type": "string",
"description": "how it ended, in words"
}
},
"required": [
"key",
"asker",
"headline",
"state",
"opened",
"expires"
]
},
"description": "one ask the router holds and how it stands"
}
@@ -406,5 +406,24 @@ export interface ClosedHolderEvent {
}
/** is one value of the state asks under the key <asker>.<id>. */
export type AsksByAskerIDValue = Record<string, unknown>;
export interface AsksByAskerIDValue {
/** how it ended, in words */
"answer"?: string;
/** its asker */
"asker": string;
/** its asker has been told how it ended */
"asker_told"?: boolean;
/** when it ends unanswered, RFC 3339 */
"expires": string;
/** its headline */
"headline": string;
/** <asker>.<id> */
"key": string;
/** the channels it was offered on */
"offered_on"?: string[];
/** when it was opened, RFC 3339 */
"opened": string;
/** how it stands: open, chosen, expired, cancelled, replaced or refused */
"state": string;
}