Send a document title as a verae-meta-git/1 history.

A non-blank document name is the first snapshot title, with restamp left false so a reprint keeps the first job. A blank name stays digest-only.
This commit is contained in:
Cursor Agent 2026-10-11 05:37:25 +00:00
commit 48426bce0e
No known key found for this signature in database
4 changed files with 493 additions and 6 deletions

View file

@ -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
}

View file

@ -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")
}
}

View file

@ -12,6 +12,7 @@ import (
"time"
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/auth"
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/metadatahist"
)
const (
@ -73,18 +74,30 @@ func (c *Client) Stamp(digest, documentName string) (Receipt, error) {
case auth.ChannelZapier:
return c.stampZapier(digest, documentName)
default:
return c.stampVerae(digest)
return c.stampVerae(digest, documentName)
}
}
func (c *Client) stampVerae(digest string) (Receipt, error) {
func historyForTitle(documentName string) map[string]any {
title := strings.TrimSpace(documentName)
if title == "" {
return nil
}
return metadatahist.Append(nil, map[string]any{"title": title}, false, "", "", "stamp")
}
func (c *Client) stampVerae(digest, documentName string) (Receipt, error) {
base := c.veraeBase()
body, _ := json.Marshal(map[string]any{
payload := map[string]any{
"data": digest,
"digest": digest,
"hashAlg": "SHA256",
"restamp": false,
})
}
if doc := historyForTitle(documentName); doc != nil {
payload["metadata"] = doc
}
body, _ := json.Marshal(payload)
req, err := http.NewRequest(http.MethodPost, base+"/api/timestamp", bytes.NewReader(body))
if err != nil {
return Receipt{}, err
@ -287,12 +300,16 @@ func (c *Client) enrichFromHash(base string, rec Receipt) (Receipt, error) {
func (c *Client) stampZapier(digest, documentName string) (Receipt, error) {
base := c.zapierBase()
body, _ := json.Marshal(map[string]any{
payload := map[string]any{
"sha256": digest,
"attachPdf": true,
"wait": true,
"documentName": documentName,
})
}
if doc := historyForTitle(documentName); doc != nil {
payload["metadata"] = doc
}
body, _ := json.Marshal(payload)
req, err := http.NewRequest(http.MethodPost, base+"/v1/timestamp", bytes.NewReader(body))
if err != nil {
return Receipt{}, err

View file

@ -2,16 +2,93 @@ package veraeapi
import (
"encoding/base64"
"encoding/json"
"io"
"net/http"
"strings"
"testing"
"time"
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/auth"
)
const digest64 = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
func TestStampVeraeSendsTitleHistory(t *testing.T) {
var posted map[string]any
c := &Client{
Channel: auth.ChannelVerae,
VeraeBase: "https://api.example.test",
HTTPDo: func(req *http.Request) (*http.Response, error) {
if req.URL.Path == "/api/timestamp" {
raw, _ := io.ReadAll(req.Body)
_ = json.Unmarshal(raw, &posted)
body := `{"jobId":"job-1","certificate":"QmHist","existing":false}`
return &http.Response{StatusCode: 202, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)}, nil
}
if strings.HasPrefix(req.URL.Path, "/api/status/") {
body := `{"status":"completed","result":"QmHist","completedAt":"2026-10-11T01:00:00Z"}`
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)}, nil
}
if strings.HasPrefix(req.URL.Path, "/api/hash/") || req.URL.Path == "/api/verify" {
return &http.Response{StatusCode: 404, Body: io.NopCloser(strings.NewReader(`{}`)), Header: make(http.Header)}, nil
}
t.Fatalf("path %s", req.URL.Path)
return nil, nil
},
}
c.PollInterval = time.Millisecond
if _, err := c.Stamp(digest64, "Deed.pdf"); err != nil {
t.Fatal(err)
}
meta, _ := posted["metadata"].(map[string]any)
if meta["schema"] != "verae-meta-git/1" {
t.Fatalf("metadata=%v", posted["metadata"])
}
snap, _ := meta["snapshot"].(map[string]any)
if snap["title"] != "Deed.pdf" {
t.Fatalf("snapshot=%v", snap)
}
if posted["restamp"] != false {
t.Fatalf("restamp=%v", posted["restamp"])
}
}
func TestStampVeraeOmitsMetadataWithoutTitle(t *testing.T) {
var posted map[string]any
c := &Client{
Channel: auth.ChannelVerae,
VeraeBase: "https://api.example.test",
HTTPDo: func(req *http.Request) (*http.Response, error) {
if req.URL.Path == "/api/timestamp" {
raw, _ := io.ReadAll(req.Body)
_ = json.Unmarshal(raw, &posted)
body := `{"jobId":"job-1","certificate":"QmPlain","existing":false}`
return &http.Response{StatusCode: 202, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)}, nil
}
if strings.HasPrefix(req.URL.Path, "/api/status/") {
body := `{"status":"completed","result":"QmPlain","completedAt":"2026-10-11T01:00:00Z"}`
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)}, nil
}
if strings.HasPrefix(req.URL.Path, "/api/hash/") || req.URL.Path == "/api/verify" {
return &http.Response{StatusCode: 404, Body: io.NopCloser(strings.NewReader(`{}`)), Header: make(http.Header)}, nil
}
t.Fatalf("path %s", req.URL.Path)
return nil, nil
},
}
c.PollInterval = time.Millisecond
if _, err := c.Stamp(digest64, " "); err != nil {
t.Fatal(err)
}
if _, ok := posted["metadata"]; ok {
t.Fatalf("metadata=%v", posted["metadata"])
}
if posted["restamp"] != false {
t.Fatalf("restamp=%v", posted["restamp"])
}
}
func TestStampVeraeImmediateCertificate(t *testing.T) {
c := &Client{
Channel: auth.ChannelVerae,