Send global stamps as a verae-meta-git/1 history.
Optional metadata or restamp builds the same commit history as node-red. A call that omits both stays digest-only with restamp false. Local and shared stamps reject metadata.
This commit is contained in:
parent
ec899f5e11
commit
deb0e251de
7 changed files with 608 additions and 11 deletions
341
internal/metadatahist/history.go
Normal file
341
internal/metadatahist/history.go
Normal 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
|
||||||
|
}
|
||||||
52
internal/metadatahist/history_test.go
Normal file
52
internal/metadatahist/history_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -49,7 +49,11 @@ func (d *Deps) stamp(ctx context.Context, req *mcp.CallToolRequest, in StampArgs
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, ReceiptsOut{}, err
|
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 {
|
if err != nil {
|
||||||
return nil, ReceiptsOut{}, err
|
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
|
return nil, ReceiptsOut{Receipts: []ReceiptDTO{fromGlobal(rec)}}, nil
|
||||||
case "local", "shared":
|
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 {
|
if d.Ledger == nil {
|
||||||
return nil, ReceiptsOut{}, fmt.Errorf("ledger not configured")
|
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 {
|
if err != nil {
|
||||||
return nil, StampBatchOut{}, err
|
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 {
|
if err != nil {
|
||||||
return nil, StampBatchOut{}, err
|
return nil, StampBatchOut{}, err
|
||||||
}
|
}
|
||||||
|
|
@ -108,6 +129,9 @@ func (d *Deps) stampBatch(ctx context.Context, req *mcp.CallToolRequest, in Stam
|
||||||
}
|
}
|
||||||
return nil, out, nil
|
return nil, out, nil
|
||||||
case "local", "shared":
|
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 {
|
if d.Ledger == nil {
|
||||||
return nil, StampBatchOut{}, fmt.Errorf("ledger not configured")
|
return nil, StampBatchOut{}, fmt.Errorf("ledger not configured")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ func Register(s *mcp.Server, d *Deps) {
|
||||||
}
|
}
|
||||||
mcp.AddTool(s, &mcp.Tool{
|
mcp.AddTool(s, &mcp.Tool{
|
||||||
Name: "stamp",
|
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)
|
}, d.stamp)
|
||||||
mcp.AddTool(s, &mcp.Tool{
|
mcp.AddTool(s, &mcp.Tool{
|
||||||
Name: "stamp_batch",
|
Name: "stamp_batch",
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,13 @@ package tools
|
||||||
|
|
||||||
// StampArgs is the stamp tool input.
|
// StampArgs is the stamp tool input.
|
||||||
type StampArgs struct {
|
type StampArgs struct {
|
||||||
SHA256 string `json:"sha256" jsonschema:"SHA-256 digest (64 hex; optional sha256: prefix)"`
|
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)"`
|
Scope string `json:"scope,omitempty" jsonschema:"local, shared, or global (default local)"`
|
||||||
CubeID string `json:"cube_id,omitempty" jsonschema:"optional cube id"`
|
CubeID string `json:"cube_id,omitempty" jsonschema:"optional cube id"`
|
||||||
OrgID string `json:"org_id,omitempty" jsonschema:"optional org 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.
|
// VerifyArgs is the verify tool input.
|
||||||
|
|
@ -85,8 +88,11 @@ type CreateUserOut struct {
|
||||||
|
|
||||||
// StampBatchArgs is the stamp_batch tool input (DEC-659).
|
// StampBatchArgs is the stamp_batch tool input (DEC-659).
|
||||||
type StampBatchArgs struct {
|
type StampBatchArgs struct {
|
||||||
SHA256s []string `json:"sha256s" jsonschema:"SHA-256 digests (64 hex each), 1..500"`
|
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"`
|
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.
|
// BatchItemDTO is one item of a stamp_batch answer.
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.georgelambert.org/marchon/verae-mcp-timestamping-server-go/internal/metadatahist"
|
||||||
"git.georgelambert.org/marchon/verae-mcp-timestamping-server-go/pkg/digest"
|
"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.
|
// BaseURL returns the API base URL.
|
||||||
func (c *Client) BaseURL() string { return c.baseURL }
|
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) {
|
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)
|
h, err := digest.NormalizeSHA256(dig)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|
@ -167,6 +184,9 @@ func (c *Client) Stamp(ctx context.Context, dig, bearer string) (*Receipt, error
|
||||||
"hashAlg": "SHA256",
|
"hashAlg": "SHA256",
|
||||||
"restamp": false,
|
"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)
|
status, body, err := c.doJSON(ctx, http.MethodPost, c.baseURL+"/api/timestamp", payload, bearer)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|
@ -175,6 +195,9 @@ func (c *Client) Stamp(ctx context.Context, dig, bearer string) (*Receipt, error
|
||||||
return nil, authErr(status, body)
|
return nil, authErr(status, body)
|
||||||
}
|
}
|
||||||
if status == http.StatusOK && (str(body, "certificate") != "" || truthy(body["existing"])) {
|
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")
|
cid := str(body, "certificate")
|
||||||
if cid != "" {
|
if cid != "" {
|
||||||
return &Receipt{
|
return &Receipt{
|
||||||
|
|
@ -641,15 +664,105 @@ type BatchResult struct {
|
||||||
Results []BatchItem `json:"results"`
|
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.
|
// 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) {
|
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))
|
items := make([]map[string]any, 0, len(digests))
|
||||||
for i, d := range digests {
|
for i, d := range digests {
|
||||||
h, err := digest.NormalizeSHA256(d)
|
h, err := digest.NormalizeSHA256(d)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("sha256s[%d]: %w", i, err)
|
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)
|
status, body, err := c.doJSON(ctx, http.MethodPost, c.baseURL+"/api/batch/timestamp", map[string]any{"items": items}, bearer)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
func TestKeeper_UsesFreshStoredToken(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
path := dir + "/auth.properties"
|
path := dir + "/auth.properties"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue