diff --git a/internal/metadatahist/history.go b/internal/metadatahist/history.go new file mode 100644 index 0000000..a04fa54 --- /dev/null +++ b/internal/metadatahist/history.go @@ -0,0 +1,341 @@ +// Package metadatahist builds verae-meta-git/1 documents the same way as +// node-red-contrib-verae lib/metadata-history.js. Blank values do not delete +// fields. A document that already uses the schema is stored as sent. +package metadatahist + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "sort" + "time" +) + +const Schema = "verae-meta-git/1" + +func IsBlank(value any) bool { + if value == nil { + return true + } + switch v := value.(type) { + case string: + for _, r := range v { + if r != ' ' && r != '\t' && r != '\n' && r != '\r' { + return false + } + } + return true + case []any: + return len(v) == 0 + case map[string]any: + return len(v) == 0 + default: + return false + } +} + +func isObject(value any) (map[string]any, bool) { + m, ok := value.(map[string]any) + return m, ok +} + +func clone(value any) any { + switch v := value.(type) { + case []any: + out := make([]any, len(v)) + for i := range v { + out[i] = clone(v[i]) + } + return out + case map[string]any: + out := make(map[string]any, len(v)) + for k, item := range v { + out[k] = clone(item) + } + return out + default: + return v + } +} + +func jsonScalar(value any) string { + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetEscapeHTML(false) + _ = enc.Encode(value) + b := bytes.TrimRight(buf.Bytes(), "\n") + return string(b) +} + +func canonical(value any) string { + switch v := value.(type) { + case []any: + parts := make([]string, len(v)) + for i := range v { + parts[i] = canonical(v[i]) + } + return "[" + join(parts) + "]" + case map[string]any: + keys := make([]string, 0, len(v)) + for k := range v { + keys = append(keys, k) + } + sort.Strings(keys) + parts := make([]string, len(keys)) + for i, k := range keys { + parts[i] = jsonScalar(k) + ":" + canonical(v[k]) + } + return "{" + join(parts) + "}" + default: + return jsonScalar(value) + } +} + +func join(parts []string) string { + out := "" + for i, p := range parts { + if i > 0 { + out += "," + } + out += p + } + return out +} + +func same(a, b any) bool { return canonical(a) == canonical(b) } + +func ExtendSnapshot(base, patch map[string]any) map[string]any { + out := map[string]any{} + if base != nil { + if cloned, ok := clone(base).(map[string]any); ok { + out = cloned + } + } + if patch == nil { + return out + } + for key, next := range patch { + if IsBlank(next) { + continue + } + prev, has := out[key] + prevObj, prevIsObj := isObject(prev) + nextObj, nextIsObj := isObject(next) + prevArr, prevIsArr := prev.([]any) + nextArr, nextIsArr := next.([]any) + switch { + case has && prevIsObj && nextIsObj: + out[key] = ExtendSnapshot(prevObj, nextObj) + case prevIsArr && nextIsArr: + extra := nonBlank(nextArr) + if len(extra) > 0 { + out[key] = append(append([]any{}, prevArr...), extra...) + } + case nextIsArr: + extra := nonBlank(nextArr) + if len(extra) == 0 { + continue + } + if prevIsArr { + out[key] = append(append([]any{}, prevArr...), extra...) + } else { + out[key] = extra + } + case nextIsObj: + nestedBase := map[string]any{} + if prevIsObj { + nestedBase = prevObj + } + nested := ExtendSnapshot(nestedBase, nextObj) + if !IsBlank(nested) { + out[key] = nested + } + default: + out[key] = clone(next) + } + } + return out +} + +func nonBlank(items []any) []any { + out := make([]any, 0, len(items)) + for _, item := range items { + if !IsBlank(item) { + out = append(out, clone(item)) + } + } + return out +} + +func ChangesBetween(before, after map[string]any) map[string]any { + added := map[string]any{} + extended := map[string]any{} + updated := map[string]any{} + if before == nil { + before = map[string]any{} + } + if after == nil { + after = map[string]any{} + } + keys := map[string]struct{}{} + for k := range before { + keys[k] = struct{}{} + } + for k := range after { + keys[k] = struct{}{} + } + ordered := make([]string, 0, len(keys)) + for k := range keys { + ordered = append(ordered, k) + } + sort.Strings(ordered) + for _, key := range ordered { + next, hasNext := after[key] + if !hasNext { + continue + } + prev, hasPrev := before[key] + if !hasPrev { + added[key] = clone(next) + continue + } + if same(prev, next) { + continue + } + prevObj, prevIsObj := isObject(prev) + nextObj, nextIsObj := isObject(next) + prevArr, prevIsArr := prev.([]any) + nextArr, nextIsArr := next.([]any) + switch { + case prevIsObj && nextIsObj: + nested := ChangesBetween(prevObj, nextObj) + if !IsBlank(nested["added"]) || !IsBlank(nested["extended"]) || !IsBlank(nested["updated"]) { + extended[key] = nested + } + case prevIsArr && nextIsArr && len(nextArr) >= len(prevArr): + appended := nextArr[len(prevArr):] + if len(appended) > 0 { + extended[key] = map[string]any{"appended": clone(appended)} + } + default: + updated[key] = map[string]any{"from": clone(prev), "to": clone(next)} + } + } + return map[string]any{"added": added, "extended": extended, "updated": updated} +} + +func commitID(commit map[string]any) string { + material := canonical(map[string]any{ + "parent": commit["parent"], + "at": commit["at"], + "message": commit["message"], + "snapshot": commit["snapshot"], + }) + sum := sha256.Sum256([]byte(material)) + return hex.EncodeToString(sum[:]) +} + +func IsHistory(value any) bool { + m, ok := isObject(value) + if !ok || m["schema"] != Schema { + return false + } + if _, ok := m["commits"].([]any); !ok { + return false + } + _, ok = isObject(m["snapshot"]) + return ok +} + +func historyFromSnapshot(snapshot map[string]any, at string) map[string]any { + stored, _ := clone(snapshot).(map[string]any) + commit := map[string]any{ + "parent": nil, + "at": at, + "message": "stamp", + "snapshot": stored, + "changes": ChangesBetween(map[string]any{}, stored), + } + commit["id"] = commitID(commit) + return map[string]any{ + "schema": Schema, + "head": commit["id"], + "snapshot": clone(stored), + "commits": []any{commit}, + } +} + +func coerceHistory(existing any, at string) map[string]any { + if IsHistory(existing) { + cloned, _ := clone(existing).(map[string]any) + return cloned + } + if m, ok := isObject(existing); ok && !IsBlank(m) { + return historyFromSnapshot(m, at) + } + return map[string]any{ + "schema": Schema, + "head": nil, + "snapshot": map[string]any{}, + "commits": []any{}, + } +} + +func hasChanges(changes map[string]any) bool { + return !IsBlank(changes["added"]) || !IsBlank(changes["extended"]) || !IsBlank(changes["updated"]) +} + +// Append builds the next history document. A nil result means the caller +// should omit metadata. restamp with a blank patch still appends one commit. +func Append(existing any, patch map[string]any, restamp bool, at, priorAt, message string) map[string]any { + if at == "" { + at = time.Now().UTC().Format(time.RFC3339Nano) + } + blankPatch := patch == nil || IsBlank(patch) || IsBlank(ExtendSnapshot(map[string]any{}, patch)) + if !restamp && blankPatch && !IsHistory(existing) && (existing == nil || IsBlank(existing)) { + return nil + } + if priorAt == "" { + priorAt = at + } + doc := coerceHistory(existing, priorAt) + head, _ := doc["head"].(string) + if !restamp && blankPatch { + if head != "" { + return doc + } + return nil + } + base, _ := doc["snapshot"].(map[string]any) + snapshot := ExtendSnapshot(base, patch) + changes := ChangesBetween(base, snapshot) + if !restamp && !hasChanges(changes) && head != "" { + return doc + } + msg := message + if msg == "" { + if head != "" { + msg = "restamp" + } else { + msg = "stamp" + } + } + var parent any + if head != "" { + parent = head + } + commit := map[string]any{ + "parent": parent, + "at": at, + "message": msg, + "snapshot": snapshot, + "changes": changes, + } + commit["id"] = commitID(commit) + commits, _ := doc["commits"].([]any) + doc["commits"] = append(commits, commit) + doc["head"] = commit["id"] + doc["snapshot"] = snapshot + return doc +} diff --git a/internal/metadatahist/history_test.go b/internal/metadatahist/history_test.go new file mode 100644 index 0000000..6bd84e2 --- /dev/null +++ b/internal/metadatahist/history_test.go @@ -0,0 +1,52 @@ +package metadatahist + +import "testing" + +func TestAppendMatchesNodeReference(t *testing.T) { + first := Append(nil, map[string]any{"title": "deed", "tags": []any{"a"}}, false, "2026-10-11T01:00:00.000Z", "", "stamp") + if first["head"] != "161ff3344ba776ae7201b73914c0e7500116ac9dde02e1967a761b7089c41fa7" { + t.Fatalf("head1=%v", first["head"]) + } + second := Append(first, map[string]any{"title": "deed-2", "tags": []any{"b"}, "place": "office"}, true, "2026-10-11T02:00:00.000Z", "", "correct the title") + if second["head"] != "c606e41ccbf4c0f9f653b21759df83ecda25262621075db3a153b592270fd918" { + t.Fatalf("head2=%v", second["head"]) + } + snap := second["snapshot"].(map[string]any) + if snap["title"] != "deed-2" || snap["place"] != "office" { + t.Fatalf("snapshot=%v", snap) + } + tags := snap["tags"].([]any) + if len(tags) != 2 || tags[0] != "a" || tags[1] != "b" { + t.Fatalf("tags=%v", tags) + } + commits := second["commits"].([]any) + tip := commits[1].(map[string]any) + if tip["parent"] != first["head"] { + t.Fatalf("parent=%v", tip["parent"]) + } + if _, ok := tip["changes"].(map[string]any)["removed"]; ok { + t.Fatal("removed key") + } +} + +func TestBlankRestampKeepsSnapshot(t *testing.T) { + base := map[string]any{"title": "deed", "note": "keep", "tags": []any{"a"}} + next := Append(base, map[string]any{"title": "", "note": nil, "tags": []any{}}, true, "2026-10-11T01:47:00.000Z", "2026-10-11T01:00:00.000Z", "") + snap := next["snapshot"].(map[string]any) + if snap["title"] != "deed" || snap["note"] != "keep" { + t.Fatalf("snapshot=%v", snap) + } + commits := next["commits"].([]any) + if len(commits) != 2 { + t.Fatalf("commits=%d", len(commits)) + } +} + +func TestOmitWhenEmpty(t *testing.T) { + if Append(nil, nil, false, "2026-10-11T01:00:00.000Z", "", "") != nil { + t.Fatal("expected omit") + } + if Append(nil, map[string]any{}, false, "2026-10-11T01:00:00.000Z", "", "") != nil { + t.Fatal("expected omit") + } +} diff --git a/internal/tools/handlers.go b/internal/tools/handlers.go index c1651ba..6933a9e 100644 --- a/internal/tools/handlers.go +++ b/internal/tools/handlers.go @@ -49,7 +49,11 @@ func (d *Deps) stamp(ctx context.Context, req *mcp.CallToolRequest, in StampArgs if err != nil { return nil, ReceiptsOut{}, err } - rec, err := d.Global.Stamp(ctx, in.SHA256, tok) + rec, err := d.Global.StampWithMeta(ctx, in.SHA256, tok, veraetime.StampMeta{ + Metadata: in.Metadata, + Restamp: in.Restamp, + Message: in.Message, + }) if err != nil { return nil, ReceiptsOut{}, err } @@ -61,6 +65,9 @@ func (d *Deps) stamp(ctx context.Context, req *mcp.CallToolRequest, in StampArgs } return nil, ReceiptsOut{Receipts: []ReceiptDTO{fromGlobal(rec)}}, nil case "local", "shared": + if len(in.Metadata) > 0 || in.Restamp { + return nil, ReceiptsOut{}, fmt.Errorf("metadata history is stored on global stamps") + } if d.Ledger == nil { return nil, ReceiptsOut{}, fmt.Errorf("ledger not configured") } @@ -98,7 +105,21 @@ func (d *Deps) stampBatch(ctx context.Context, req *mcp.CallToolRequest, in Stam if err != nil { return nil, StampBatchOut{}, err } - br, err := d.Global.StampBatch(ctx, in.SHA256s, tok) + var metas []veraetime.StampMeta + if len(in.Metadata) > 0 || in.Restamp { + if len(in.Metadata) > 0 && len(in.Metadata) != len(in.SHA256s) { + return nil, StampBatchOut{}, fmt.Errorf("metadata must have one entry per digest") + } + metas = make([]veraetime.StampMeta, len(in.SHA256s)) + for i := range in.SHA256s { + metas[i].Restamp = in.Restamp + metas[i].Message = in.Message + if i < len(in.Metadata) { + metas[i].Metadata = in.Metadata[i] + } + } + } + br, err := d.Global.StampBatchWithMeta(ctx, in.SHA256s, tok, metas) if err != nil { return nil, StampBatchOut{}, err } @@ -108,6 +129,9 @@ func (d *Deps) stampBatch(ctx context.Context, req *mcp.CallToolRequest, in Stam } return nil, out, nil case "local", "shared": + if len(in.Metadata) > 0 || in.Restamp { + return nil, StampBatchOut{}, fmt.Errorf("metadata history is stored on global stamps") + } if d.Ledger == nil { return nil, StampBatchOut{}, fmt.Errorf("ledger not configured") } diff --git a/internal/tools/register.go b/internal/tools/register.go index 0498942..c062187 100644 --- a/internal/tools/register.go +++ b/internal/tools/register.go @@ -27,7 +27,7 @@ func Register(s *mcp.Server, d *Deps) { } mcp.AddTool(s, &mcp.Tool{ Name: "stamp", - Description: "Commit a SHA-256. scope=local is the leaf file ledger; scope=global posts digest and restamp=false to api.veraetime.net and reuses the first CID.", + Description: "Commit a SHA-256. scope=local is the leaf file ledger. scope=global posts the digest to api.veraetime.net and reuses the first CID unless restamp is true. Optional metadata is sent as a verae-meta-git/1 history; blank values do not delete fields.", }, d.stamp) mcp.AddTool(s, &mcp.Tool{ Name: "stamp_batch", diff --git a/internal/tools/types.go b/internal/tools/types.go index 235c987..3ba8481 100644 --- a/internal/tools/types.go +++ b/internal/tools/types.go @@ -2,10 +2,13 @@ package tools // StampArgs is the stamp tool input. type StampArgs struct { - SHA256 string `json:"sha256" jsonschema:"SHA-256 digest (64 hex; optional sha256: prefix)"` - Scope string `json:"scope,omitempty" jsonschema:"local, shared, or global (default local)"` - CubeID string `json:"cube_id,omitempty" jsonschema:"optional cube id"` - OrgID string `json:"org_id,omitempty" jsonschema:"optional org id"` + SHA256 string `json:"sha256" jsonschema:"SHA-256 digest (64 hex; optional sha256: prefix)"` + Scope string `json:"scope,omitempty" jsonschema:"local, shared, or global (default local)"` + CubeID string `json:"cube_id,omitempty" jsonschema:"optional cube id"` + OrgID string `json:"org_id,omitempty" jsonschema:"optional org id"` + Metadata map[string]any `json:"metadata,omitempty" jsonschema:"optional plain object or a verae-meta-git/1 history. Blank values do not delete fields. Global scope only."` + Restamp bool `json:"restamp,omitempty" jsonschema:"when true, append metadata onto the previous global stamp for this digest"` + Message string `json:"message,omitempty" jsonschema:"optional history commit message"` } // VerifyArgs is the verify tool input. @@ -85,8 +88,11 @@ type CreateUserOut struct { // StampBatchArgs is the stamp_batch tool input (DEC-659). type StampBatchArgs struct { - SHA256s []string `json:"sha256s" jsonschema:"SHA-256 digests (64 hex each), 1..500"` - Scope string `json:"scope,omitempty" jsonschema:"global (default; charged per new digest to your Verae account) or local"` + SHA256s []string `json:"sha256s" jsonschema:"SHA-256 digests (64 hex each), 1..500"` + Scope string `json:"scope,omitempty" jsonschema:"global (default; charged per new digest to your Verae account) or local"` + Metadata []map[string]any `json:"metadata,omitempty" jsonschema:"optional history patch per digest, same length as sha256s. Global scope only."` + Restamp bool `json:"restamp,omitempty" jsonschema:"when true, append each metadata patch onto the previous global stamp"` + Message string `json:"message,omitempty" jsonschema:"optional history commit message for every item"` } // BatchItemDTO is one item of a stamp_batch answer. diff --git a/internal/veraetime/client.go b/internal/veraetime/client.go index b497165..c4c60fa 100644 --- a/internal/veraetime/client.go +++ b/internal/veraetime/client.go @@ -10,6 +10,7 @@ import ( "strings" "time" + "git.georgelambert.org/marchon/verae-mcp-timestamping-server-go/internal/metadatahist" "git.georgelambert.org/marchon/verae-mcp-timestamping-server-go/pkg/digest" ) @@ -155,8 +156,24 @@ func (c *Client) AuthPath() string { return c.authPath } // BaseURL returns the API base URL. func (c *Client) BaseURL() string { return c.baseURL } -// Stamp posts a SHA-256 digest with restamp=false. +// StampMeta is optional history for one stamp. The zero value keeps the +// digest-only body: restamp false and no metadata field. +type StampMeta struct { + Metadata map[string]any + Restamp bool + Message string + At string +} + +// Stamp posts a SHA-256 digest with restamp=false and no metadata. func (c *Client) Stamp(ctx context.Context, dig, bearer string) (*Receipt, error) { + return c.StampWithMeta(ctx, dig, bearer, StampMeta{}) +} + +// StampWithMeta posts a digest. Metadata or Restamp sends a verae-meta-git/1 +// document. A second submit of an existing digest without Restamp, when +// metadata was sent, returns an error so the first history is left unchanged. +func (c *Client) StampWithMeta(ctx context.Context, dig, bearer string, meta StampMeta) (*Receipt, error) { h, err := digest.NormalizeSHA256(dig) if err != nil { return nil, err @@ -167,6 +184,9 @@ func (c *Client) Stamp(ctx context.Context, dig, bearer string) (*Receipt, error "hashAlg": "SHA256", "restamp": false, } + if err := c.attachHistory(ctx, h, bearer, meta, payload); err != nil { + return nil, err + } status, body, err := c.doJSON(ctx, http.MethodPost, c.baseURL+"/api/timestamp", payload, bearer) if err != nil { return nil, err @@ -175,6 +195,9 @@ func (c *Client) Stamp(ctx context.Context, dig, bearer string) (*Receipt, error return nil, authErr(status, body) } if status == http.StatusOK && (str(body, "certificate") != "" || truthy(body["existing"])) { + if payload["metadata"] != nil && !truthy(payload["restamp"]) { + return nil, fmt.Errorf("veraetime: digest already stamped; set restamp to append metadata") + } cid := str(body, "certificate") if cid != "" { return &Receipt{ @@ -641,15 +664,105 @@ type BatchResult struct { Results []BatchItem `json:"results"` } +func (c *Client) attachHistory(ctx context.Context, dig, bearer string, meta StampMeta, payload map[string]any) error { + if len(meta.Metadata) == 0 && !meta.Restamp && !metadatahist.IsHistory(meta.Metadata) { + return nil + } + var prior any + if meta.Restamp && !metadatahist.IsHistory(meta.Metadata) { + loaded, err := c.priorMetadata(ctx, dig, bearer) + if err != nil { + return err + } + prior = loaded + } + doc := metadatahist.Append(prior, meta.Metadata, meta.Restamp, meta.At, "", meta.Message) + if metadatahist.IsHistory(meta.Metadata) { + doc = meta.Metadata + } + if doc == nil { + return nil + } + payload["metadata"] = doc + if meta.Restamp { + payload["restamp"] = true + } + if commits, ok := doc["commits"].([]any); ok && len(commits) > 1 { + payload["restamp"] = true + } + return nil +} + +func (c *Client) priorMetadata(ctx context.Context, dig, bearer string) (map[string]any, error) { + status, body, err := c.doJSON(ctx, http.MethodGet, c.baseURL+"/api/hash/"+dig, nil, bearer) + if err != nil { + return nil, err + } + if status == http.StatusNotFound { + return nil, nil + } + if status == http.StatusUnauthorized { + return nil, authErr(status, body) + } + if status >= 300 { + return nil, httpErr("hash report", status, body) + } + refs, _ := body["receipts"].([]any) + jobID := "" + for _, refAny := range refs { + ref, ok := refAny.(map[string]any) + if !ok { + continue + } + if id := str(ref, "jobId"); id != "" { + jobID = id + } + } + if jobID == "" { + return nil, nil + } + st, sb, err := c.doJSON(ctx, http.MethodGet, c.baseURL+"/api/status/"+jobID, nil, bearer) + if err != nil { + return nil, err + } + if st == http.StatusNotFound { + return nil, nil + } + if st == http.StatusUnauthorized { + return nil, authErr(st, sb) + } + if st >= 300 { + return nil, httpErr("status", st, sb) + } + meta, _ := sb["metadata"].(map[string]any) + return meta, nil +} + // StampBatch posts SHA-256 digests (restamp=false) in one batch; each new digest is charged to the key's customer. func (c *Client) StampBatch(ctx context.Context, digests []string, bearer string) (*BatchResult, error) { + return c.StampBatchWithMeta(ctx, digests, bearer, nil) +} + +// StampBatchWithMeta is StampBatch with one optional history per digest. +// A nil metas slice keeps every item digest-only. A non-nil slice must match +// len(digests). +func (c *Client) StampBatchWithMeta(ctx context.Context, digests []string, bearer string, metas []StampMeta) (*BatchResult, error) { + if metas != nil && len(metas) != len(digests) { + return nil, fmt.Errorf("veraetime: metadata entries (%d) must match digests (%d)", len(metas), len(digests)) + } items := make([]map[string]any, 0, len(digests)) for i, d := range digests { h, err := digest.NormalizeSHA256(d) if err != nil { return nil, fmt.Errorf("sha256s[%d]: %w", i, err) } - items = append(items, map[string]any{"data": h, "digest": h, "hashAlg": "SHA256", "restamp": false}) + item := map[string]any{"data": h, "digest": h, "hashAlg": "SHA256", "restamp": false} + if metas != nil { + if err := c.attachHistory(ctx, h, bearer, metas[i], item); err != nil { + return nil, err + } + } + items = append(items, item) } status, body, err := c.doJSON(ctx, http.MethodPost, c.baseURL+"/api/batch/timestamp", map[string]any{"items": items}, bearer) if err != nil { diff --git a/internal/veraetime/client_test.go b/internal/veraetime/client_test.go index f2da22a..493ac48 100644 --- a/internal/veraetime/client_test.go +++ b/internal/veraetime/client_test.go @@ -381,6 +381,67 @@ func TestKeeper_UsesGlobalTokenEnv(t *testing.T) { } } +func TestStampWithMeta_SendsHistoryAndExtendsOnRestamp(t *testing.T) { + var posted []map[string]any + rt := roundTripFunc(func(r *http.Request) (*http.Response, error) { + switch { + case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/api/timestamp"): + posted = append(posted, readJSON(t, r)) + return jsonResp(202, map[string]any{"jobId": "job-new"}), nil + case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/api/hash/"): + return jsonResp(200, map[string]any{ + "receipts": []any{map[string]any{"jobId": "job-old", "certificate": "QmOld"}}, + }), nil + case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/api/status/job-old"): + return jsonResp(200, map[string]any{ + "status": "completed", + "metadata": map[string]any{ + "title": "deed", + "tags": []any{"a"}, + }, + }), nil + case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/api/status/job-new"): + return jsonResp(200, map[string]any{ + "status": "completed", + "result": "QmNew", + "metadata": map[string]any{ + "schema": "verae-meta-git/1", + }, + }), nil + default: + t.Fatalf("unexpected %s %s", r.Method, r.URL.Path) + return nil, nil + } + }) + c := veraetime.NewClient("https://api.example", veraetime.WithRoundTripper(rt), veraetime.WithPollInterval(time.Millisecond)) + _, err := c.StampWithMeta(context.Background(), sha256Empty, "tok", veraetime.StampMeta{ + Metadata: map[string]any{"place": "office"}, + Restamp: true, + Message: "add the place", + At: "2026-10-11T02:00:00.000Z", + }) + if err != nil { + t.Fatal(err) + } + if len(posted) != 1 { + t.Fatalf("posts=%d", len(posted)) + } + if posted[0]["restamp"] != true { + t.Fatalf("restamp=%v", posted[0]["restamp"]) + } + doc, ok := posted[0]["metadata"].(map[string]any) + if !ok || doc["schema"] != "verae-meta-git/1" { + t.Fatalf("metadata=%v", posted[0]["metadata"]) + } + snap := doc["snapshot"].(map[string]any) + if snap["title"] != "deed" || snap["place"] != "office" { + t.Fatalf("snapshot=%v", snap) + } + if _, ok := posted[0]["metadata"]; !ok { + t.Fatal("missing metadata") + } +} + func TestKeeper_UsesFreshStoredToken(t *testing.T) { dir := t.TempDir() path := dir + "/auth.properties"