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
3cacdc36d4
commit
394c46ebfc
8 changed files with 469 additions and 21 deletions
|
|
@ -14,8 +14,9 @@ info:
|
||||||
arguments named `content`, `bytes`, `file`, `data`, `path`, `body`, `payload`,
|
arguments named `content`, `bytes`, `file`, `data`, `path`, `body`, `payload`,
|
||||||
`blob`, or `archive`.
|
`blob`, or `archive`.
|
||||||
|
|
||||||
**Global stamp:** VeraeTime body uses `restamp: false` by default (reuse existing
|
**Global stamp:** VeraeTime body uses `restamp: false` and omits metadata unless
|
||||||
certificate). Explicit restamp is out of scope unless added later.
|
the caller passes `metadata` or `restamp`. Those calls send a `verae-meta-git/1`
|
||||||
|
history. Local and shared stamps reject metadata.
|
||||||
|
|
||||||
## Mapping appendix (MCP ↔ VeraeTime ↔ Application API)
|
## Mapping appendix (MCP ↔ VeraeTime ↔ Application API)
|
||||||
|
|
||||||
|
|
@ -52,8 +53,9 @@ paths:
|
||||||
summary: Stamp a SHA-256 digest
|
summary: Stamp a SHA-256 digest
|
||||||
description: |
|
description: |
|
||||||
Request a timestamp for a digest. Scope `local` or `shared` goes to MCP tool
|
Request a timestamp for a digest. Scope `local` or `shared` goes to MCP tool
|
||||||
`stamp`; scope `global` goes to VeraeTime `POST /api/timestamp` with
|
`stamp`; scope `global` goes to VeraeTime `POST /api/timestamp`. The default
|
||||||
`restamp: false` by default.
|
body is digest-only with `restamp: false`. `metadata` or `restamp` sends a
|
||||||
|
`verae-meta-git/1` history.
|
||||||
tags: [timestamping]
|
tags: [timestamping]
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
|
|
@ -250,13 +252,27 @@ components:
|
||||||
minLength: 64
|
minLength: 64
|
||||||
scope:
|
scope:
|
||||||
type: string
|
type: string
|
||||||
description: Default local. global → VeraeTime with restamp false.
|
description: Default local. global → VeraeTime. Metadata history is global only.
|
||||||
enum: [local, shared, global]
|
enum: [local, shared, global]
|
||||||
default: local
|
default: local
|
||||||
cube_id:
|
cube_id:
|
||||||
type: string
|
type: string
|
||||||
org_id:
|
org_id:
|
||||||
type: string
|
type: string
|
||||||
|
metadata:
|
||||||
|
type: object
|
||||||
|
description: >
|
||||||
|
Plain object or a verae-meta-git/1 document. Blank values do not delete
|
||||||
|
fields. Omitted on a digest-only stamp.
|
||||||
|
additionalProperties: true
|
||||||
|
restamp:
|
||||||
|
type: boolean
|
||||||
|
description: >
|
||||||
|
When true, append metadata onto the previous global stamp for this digest.
|
||||||
|
default: false
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
|
description: History commit message.
|
||||||
StampResponse:
|
StampResponse:
|
||||||
type: object
|
type: object
|
||||||
required: [receipts]
|
required: [receipts]
|
||||||
|
|
|
||||||
|
|
@ -360,7 +360,16 @@ class GlobalClient:
|
||||||
headers["Authorization"] = f"Bearer {self.token}"
|
headers["Authorization"] = f"Bearer {self.token}"
|
||||||
return headers
|
return headers
|
||||||
|
|
||||||
def submit(self, digest: str, cube_id: str = "") -> Receipt:
|
def submit(
|
||||||
|
self,
|
||||||
|
digest: str,
|
||||||
|
cube_id: str = "",
|
||||||
|
*,
|
||||||
|
metadata: dict | None = None,
|
||||||
|
restamp: bool = False,
|
||||||
|
message: str = "",
|
||||||
|
at: str = "",
|
||||||
|
) -> Receipt:
|
||||||
override = request_token.get()
|
override = request_token.get()
|
||||||
saved = self.token
|
saved = self.token
|
||||||
if override:
|
if override:
|
||||||
|
|
@ -368,22 +377,75 @@ class GlobalClient:
|
||||||
else:
|
else:
|
||||||
self.ensure_token()
|
self.ensure_token()
|
||||||
try:
|
try:
|
||||||
return self._submit(digest, cube_id)
|
return self._submit(digest, cube_id, metadata=metadata, restamp=restamp, message=message, at=at)
|
||||||
finally:
|
finally:
|
||||||
if override:
|
if override:
|
||||||
self.token = saved
|
self.token = saved
|
||||||
|
|
||||||
def _submit(self, digest: str, cube_id: str = "") -> Receipt:
|
def _history_document(self, digest: str, metadata, restamp: bool, message: str, at: str):
|
||||||
|
from verae_mcp_timestamping_server.metadata_history import append_metadata, is_history
|
||||||
|
|
||||||
|
if not metadata and not restamp:
|
||||||
|
return None
|
||||||
|
if is_history(metadata):
|
||||||
|
return metadata
|
||||||
|
prior = None
|
||||||
|
if restamp:
|
||||||
|
prior = self._prior_metadata(digest)
|
||||||
|
return append_metadata(prior, metadata if isinstance(metadata, dict) else None, restamp=restamp, at=at or None, message=message or None)
|
||||||
|
|
||||||
|
def _prior_metadata(self, digest: str):
|
||||||
|
status, body = self._request_json("GET", f"{self.base_url}/api/hash/{digest}", None)
|
||||||
|
if status == 404 or not isinstance(body, dict):
|
||||||
|
return None
|
||||||
|
if status >= 300:
|
||||||
|
raise ValueError(f"global timestamp HTTP {status}: {body}")
|
||||||
|
job_id = ""
|
||||||
|
for ref in body.get("receipts") or []:
|
||||||
|
if isinstance(ref, dict) and ref.get("jobId"):
|
||||||
|
job_id = str(ref["jobId"])
|
||||||
|
if not job_id:
|
||||||
|
return None
|
||||||
|
st, sb = self._request_json("GET", f"{self.base_url}/api/status/{job_id}", None)
|
||||||
|
if st == 404 or not isinstance(sb, dict):
|
||||||
|
return None
|
||||||
|
if st >= 300:
|
||||||
|
raise ValueError(f"global timestamp HTTP {st}: {sb}")
|
||||||
|
meta = sb.get("metadata")
|
||||||
|
return meta if isinstance(meta, dict) else None
|
||||||
|
|
||||||
|
def _request_json(self, method: str, url: str, payload):
|
||||||
|
res = self.http.request(method, url, payload={} if payload is None else payload, headers=self._headers())
|
||||||
|
parsed = res.json() if hasattr(res, "json") else {}
|
||||||
|
return res.status_code, parsed
|
||||||
|
|
||||||
|
def _submit(
|
||||||
|
self,
|
||||||
|
digest: str,
|
||||||
|
cube_id: str = "",
|
||||||
|
*,
|
||||||
|
metadata: dict | None = None,
|
||||||
|
restamp: bool = False,
|
||||||
|
message: str = "",
|
||||||
|
at: str = "",
|
||||||
|
) -> Receipt:
|
||||||
url = f"{self.base_url}/api/timestamp"
|
url = f"{self.base_url}/api/timestamp"
|
||||||
res = self.http.request(
|
|
||||||
"POST",
|
|
||||||
url,
|
|
||||||
payload = {
|
payload = {
|
||||||
"data": digest,
|
"data": digest,
|
||||||
"digest": digest,
|
"digest": digest,
|
||||||
"hashAlg": "SHA256",
|
"hashAlg": "SHA256",
|
||||||
"restamp": False,
|
"restamp": False,
|
||||||
},
|
}
|
||||||
|
doc = self._history_document(digest, metadata, restamp, message, at)
|
||||||
|
if doc is not None:
|
||||||
|
payload["metadata"] = doc
|
||||||
|
commits = doc.get("commits") if isinstance(doc, dict) else None
|
||||||
|
if restamp or (isinstance(commits, list) and len(commits) > 1):
|
||||||
|
payload["restamp"] = True
|
||||||
|
res = self.http.request(
|
||||||
|
"POST",
|
||||||
|
url,
|
||||||
|
payload=payload,
|
||||||
headers=self._headers(),
|
headers=self._headers(),
|
||||||
)
|
)
|
||||||
parsed = res.json() if hasattr(res, "json") else {}
|
parsed = res.json() if hasattr(res, "json") else {}
|
||||||
|
|
@ -394,6 +456,8 @@ class GlobalClient:
|
||||||
if res.status_code == 200 and (
|
if res.status_code == 200 and (
|
||||||
parsed.get("certificate") or parsed.get("existing")
|
parsed.get("certificate") or parsed.get("existing")
|
||||||
):
|
):
|
||||||
|
if payload.get("metadata") is not None and not payload.get("restamp"):
|
||||||
|
raise ValueError("digest already stamped; set restamp to append metadata")
|
||||||
cid = str(parsed.get("certificate") or "")
|
cid = str(parsed.get("certificate") or "")
|
||||||
if cid:
|
if cid:
|
||||||
return Receipt(
|
return Receipt(
|
||||||
|
|
@ -560,10 +624,21 @@ class GlobalLedger:
|
||||||
scope: str = "global",
|
scope: str = "global",
|
||||||
cube_id: str = "",
|
cube_id: str = "",
|
||||||
org_id: str = "",
|
org_id: str = "",
|
||||||
|
*,
|
||||||
|
metadata: dict | None = None,
|
||||||
|
restamp: bool = False,
|
||||||
|
message: str = "",
|
||||||
) -> list[Receipt]:
|
) -> list[Receipt]:
|
||||||
del org_id
|
del org_id
|
||||||
digest = validate_sha256(sha256)
|
digest = validate_sha256(sha256)
|
||||||
receipt = await asyncio.to_thread(self.client.submit, digest, cube_id)
|
receipt = await asyncio.to_thread(
|
||||||
|
self.client.submit,
|
||||||
|
digest,
|
||||||
|
cube_id,
|
||||||
|
metadata=metadata,
|
||||||
|
restamp=restamp,
|
||||||
|
message=message,
|
||||||
|
)
|
||||||
return [receipt]
|
return [receipt]
|
||||||
|
|
||||||
async def verify(self, receipt_id: str, want_scope: str = "") -> VerifyResult:
|
async def verify(self, receipt_id: str, want_scope: str = "") -> VerifyResult:
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,10 @@ class Ledger(Protocol):
|
||||||
scope: str = "local",
|
scope: str = "local",
|
||||||
cube_id: str = "",
|
cube_id: str = "",
|
||||||
org_id: str = "",
|
org_id: str = "",
|
||||||
|
*,
|
||||||
|
metadata: dict | None = None,
|
||||||
|
restamp: bool = False,
|
||||||
|
message: str = "",
|
||||||
) -> list[Receipt]: ...
|
) -> list[Receipt]: ...
|
||||||
|
|
||||||
async def verify(self, receipt_id: str, want_scope: str = "") -> VerifyResult: ...
|
async def verify(self, receipt_id: str, want_scope: str = "") -> VerifyResult: ...
|
||||||
|
|
@ -92,7 +96,14 @@ class MemoryLedger:
|
||||||
scope: str = "local",
|
scope: str = "local",
|
||||||
cube_id: str = "",
|
cube_id: str = "",
|
||||||
org_id: str = "",
|
org_id: str = "",
|
||||||
|
*,
|
||||||
|
metadata: dict | None = None,
|
||||||
|
restamp: bool = False,
|
||||||
|
message: str = "",
|
||||||
) -> list[Receipt]:
|
) -> list[Receipt]:
|
||||||
|
del message
|
||||||
|
if metadata or restamp:
|
||||||
|
raise ValueError("metadata history is stored on global stamps")
|
||||||
digest = validate_sha256(sha256)
|
digest = validate_sha256(sha256)
|
||||||
if scope not in _SCOPE_RANK:
|
if scope not in _SCOPE_RANK:
|
||||||
raise ValueError("scope must be local, shared, or global")
|
raise ValueError("scope must be local, shared, or global")
|
||||||
|
|
@ -192,9 +203,21 @@ class FileLedger(MemoryLedger):
|
||||||
scope: str = "local",
|
scope: str = "local",
|
||||||
cube_id: str = "",
|
cube_id: str = "",
|
||||||
org_id: str = "",
|
org_id: str = "",
|
||||||
|
*,
|
||||||
|
metadata: dict | None = None,
|
||||||
|
restamp: bool = False,
|
||||||
|
message: str = "",
|
||||||
) -> list[Receipt]:
|
) -> list[Receipt]:
|
||||||
before = set(self._by_id)
|
before = set(self._by_id)
|
||||||
receipts = await super().stamp(sha256, scope=scope, cube_id=cube_id, org_id=org_id)
|
receipts = await super().stamp(
|
||||||
|
sha256,
|
||||||
|
scope=scope,
|
||||||
|
cube_id=cube_id,
|
||||||
|
org_id=org_id,
|
||||||
|
metadata=metadata,
|
||||||
|
restamp=restamp,
|
||||||
|
message=message,
|
||||||
|
)
|
||||||
if receipts[0].receipt_id not in before:
|
if receipts[0].receipt_id not in before:
|
||||||
self._save()
|
self._save()
|
||||||
return receipts
|
return receipts
|
||||||
|
|
@ -217,13 +240,29 @@ class RoutingLedger:
|
||||||
scope: str = "local",
|
scope: str = "local",
|
||||||
cube_id: str = "",
|
cube_id: str = "",
|
||||||
org_id: str = "",
|
org_id: str = "",
|
||||||
|
*,
|
||||||
|
metadata: dict | None = None,
|
||||||
|
restamp: bool = False,
|
||||||
|
message: str = "",
|
||||||
) -> list[Receipt]:
|
) -> list[Receipt]:
|
||||||
if scope == "global":
|
if scope == "global":
|
||||||
return await self.global_ledger.stamp(
|
return await self.global_ledger.stamp(
|
||||||
sha256, scope=scope, cube_id=cube_id, org_id=org_id
|
sha256,
|
||||||
|
scope=scope,
|
||||||
|
cube_id=cube_id,
|
||||||
|
org_id=org_id,
|
||||||
|
metadata=metadata,
|
||||||
|
restamp=restamp,
|
||||||
|
message=message,
|
||||||
)
|
)
|
||||||
return await self.local.stamp(
|
return await self.local.stamp(
|
||||||
sha256, scope=scope, cube_id=cube_id, org_id=org_id
|
sha256,
|
||||||
|
scope=scope,
|
||||||
|
cube_id=cube_id,
|
||||||
|
org_id=org_id,
|
||||||
|
metadata=metadata,
|
||||||
|
restamp=restamp,
|
||||||
|
message=message,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def verify(self, receipt_id: str, want_scope: str = "") -> VerifyResult:
|
async def verify(self, receipt_id: str, want_scope: str = "") -> VerifyResult:
|
||||||
|
|
|
||||||
207
src/verae_mcp_timestamping_server/metadata_history.py
Normal file
207
src/verae_mcp_timestamping_server/metadata_history.py
Normal file
|
|
@ -0,0 +1,207 @@
|
||||||
|
"""verae-meta-git/1 documents, matching node-red-contrib-verae lib/metadata-history.js."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
SCHEMA = "verae-meta-git/1"
|
||||||
|
|
||||||
|
|
||||||
|
def is_plain_object(value: Any) -> bool:
|
||||||
|
return isinstance(value, dict)
|
||||||
|
|
||||||
|
|
||||||
|
def is_blank(value: Any) -> bool:
|
||||||
|
if value is None:
|
||||||
|
return True
|
||||||
|
if isinstance(value, str):
|
||||||
|
return value.strip() == ""
|
||||||
|
if isinstance(value, list):
|
||||||
|
return len(value) == 0
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return len(value) == 0
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _clone(value: Any) -> Any:
|
||||||
|
if isinstance(value, list):
|
||||||
|
return [_clone(item) for item in value]
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return {key: _clone(item) for key, item in value.items()}
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _scalar(value: Any) -> str:
|
||||||
|
if isinstance(value, bool) or value is None or isinstance(value, str):
|
||||||
|
return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
|
||||||
|
if isinstance(value, int) and not isinstance(value, bool):
|
||||||
|
return json.dumps(value)
|
||||||
|
if isinstance(value, float):
|
||||||
|
if value.is_integer():
|
||||||
|
return json.dumps(int(value))
|
||||||
|
return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
|
||||||
|
return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
|
||||||
|
|
||||||
|
|
||||||
|
def canonical(value: Any) -> str:
|
||||||
|
if isinstance(value, list):
|
||||||
|
return "[" + ",".join(canonical(item) for item in value) + "]"
|
||||||
|
if isinstance(value, dict):
|
||||||
|
keys = sorted(value)
|
||||||
|
return "{" + ",".join(_scalar(key) + ":" + canonical(value[key]) for key in keys) + "}"
|
||||||
|
return _scalar(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _same(a: Any, b: Any) -> bool:
|
||||||
|
return canonical(a) == canonical(b)
|
||||||
|
|
||||||
|
|
||||||
|
def extend_snapshot(base: dict | None, patch: dict | None) -> dict:
|
||||||
|
out = _clone(base) if isinstance(base, dict) else {}
|
||||||
|
if not isinstance(patch, dict):
|
||||||
|
return out
|
||||||
|
for key, nxt in patch.items():
|
||||||
|
if is_blank(nxt):
|
||||||
|
continue
|
||||||
|
prev = out.get(key, None)
|
||||||
|
has = key in out
|
||||||
|
if has and isinstance(prev, dict) and isinstance(nxt, dict):
|
||||||
|
out[key] = extend_snapshot(prev, nxt)
|
||||||
|
elif isinstance(prev, list) and isinstance(nxt, list):
|
||||||
|
extra = [_clone(item) for item in nxt if not is_blank(item)]
|
||||||
|
if extra:
|
||||||
|
out[key] = list(prev) + extra
|
||||||
|
elif isinstance(nxt, list):
|
||||||
|
extra = [_clone(item) for item in nxt if not is_blank(item)]
|
||||||
|
if not extra:
|
||||||
|
continue
|
||||||
|
out[key] = (list(prev) + extra) if isinstance(prev, list) else extra
|
||||||
|
elif isinstance(nxt, dict):
|
||||||
|
nested = extend_snapshot(prev if isinstance(prev, dict) else {}, nxt)
|
||||||
|
if not is_blank(nested):
|
||||||
|
out[key] = nested
|
||||||
|
else:
|
||||||
|
out[key] = _clone(nxt)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def changes_between(before: dict | None, after: dict | None) -> dict:
|
||||||
|
added: dict[str, Any] = {}
|
||||||
|
extended: dict[str, Any] = {}
|
||||||
|
updated: dict[str, Any] = {}
|
||||||
|
prior = before if isinstance(before, dict) else {}
|
||||||
|
nxt = after if isinstance(after, dict) else {}
|
||||||
|
for key in sorted(set(prior) | set(nxt)):
|
||||||
|
if key not in nxt:
|
||||||
|
continue
|
||||||
|
if key not in prior:
|
||||||
|
added[key] = _clone(nxt[key])
|
||||||
|
continue
|
||||||
|
if _same(prior[key], nxt[key]):
|
||||||
|
continue
|
||||||
|
if isinstance(prior[key], dict) and isinstance(nxt[key], dict):
|
||||||
|
nested = changes_between(prior[key], nxt[key])
|
||||||
|
if not is_blank(nested["added"]) or not is_blank(nested["extended"]) or not is_blank(nested["updated"]):
|
||||||
|
extended[key] = nested
|
||||||
|
elif (
|
||||||
|
isinstance(prior[key], list)
|
||||||
|
and isinstance(nxt[key], list)
|
||||||
|
and len(nxt[key]) >= len(prior[key])
|
||||||
|
):
|
||||||
|
appended = nxt[key][len(prior[key]) :]
|
||||||
|
if appended:
|
||||||
|
extended[key] = {"appended": _clone(appended)}
|
||||||
|
else:
|
||||||
|
updated[key] = {"from": _clone(prior[key]), "to": _clone(nxt[key])}
|
||||||
|
return {"added": added, "extended": extended, "updated": updated}
|
||||||
|
|
||||||
|
|
||||||
|
def _commit_id(commit: dict) -> str:
|
||||||
|
material = canonical(
|
||||||
|
{
|
||||||
|
"parent": commit["parent"],
|
||||||
|
"at": commit["at"],
|
||||||
|
"message": commit["message"],
|
||||||
|
"snapshot": commit["snapshot"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return hashlib.sha256(material.encode()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def is_history(value: Any) -> bool:
|
||||||
|
return (
|
||||||
|
isinstance(value, dict)
|
||||||
|
and value.get("schema") == SCHEMA
|
||||||
|
and isinstance(value.get("commits"), list)
|
||||||
|
and isinstance(value.get("snapshot"), dict)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _history_from_snapshot(snapshot: dict, at: str) -> dict:
|
||||||
|
stored = _clone(snapshot)
|
||||||
|
commit = {
|
||||||
|
"parent": None,
|
||||||
|
"at": at,
|
||||||
|
"message": "stamp",
|
||||||
|
"snapshot": stored,
|
||||||
|
"changes": changes_between({}, stored),
|
||||||
|
}
|
||||||
|
commit["id"] = _commit_id(commit)
|
||||||
|
return {
|
||||||
|
"schema": SCHEMA,
|
||||||
|
"head": commit["id"],
|
||||||
|
"snapshot": _clone(stored),
|
||||||
|
"commits": [commit],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _coerce(existing: Any, at: str) -> dict:
|
||||||
|
if is_history(existing):
|
||||||
|
return _clone(existing)
|
||||||
|
if isinstance(existing, dict) and not is_blank(existing):
|
||||||
|
return _history_from_snapshot(existing, at)
|
||||||
|
return {"schema": SCHEMA, "head": None, "snapshot": {}, "commits": []}
|
||||||
|
|
||||||
|
|
||||||
|
def _has_changes(changes: dict) -> bool:
|
||||||
|
return not is_blank(changes["added"]) or not is_blank(changes["extended"]) or not is_blank(changes["updated"])
|
||||||
|
|
||||||
|
|
||||||
|
def append_metadata(
|
||||||
|
existing: Any,
|
||||||
|
patch: dict | None,
|
||||||
|
*,
|
||||||
|
restamp: bool = False,
|
||||||
|
at: str | None = None,
|
||||||
|
prior_at: str | None = None,
|
||||||
|
message: str | None = None,
|
||||||
|
) -> dict | None:
|
||||||
|
if not at:
|
||||||
|
at = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||||
|
blank_patch = not isinstance(patch, dict) or is_blank(patch) or is_blank(extend_snapshot({}, patch))
|
||||||
|
if not restamp and blank_patch and not is_history(existing) and (existing is None or is_blank(existing)):
|
||||||
|
return None
|
||||||
|
doc = _coerce(existing, prior_at or at)
|
||||||
|
head = doc.get("head")
|
||||||
|
if not restamp and blank_patch:
|
||||||
|
return doc if head else None
|
||||||
|
snapshot = extend_snapshot(doc.get("snapshot") or {}, patch if isinstance(patch, dict) else {})
|
||||||
|
changes = changes_between(doc.get("snapshot") or {}, snapshot)
|
||||||
|
if not restamp and not _has_changes(changes) and head:
|
||||||
|
return doc
|
||||||
|
msg = message or ("restamp" if head else "stamp")
|
||||||
|
commit = {
|
||||||
|
"parent": head,
|
||||||
|
"at": at,
|
||||||
|
"message": msg,
|
||||||
|
"snapshot": snapshot,
|
||||||
|
"changes": changes,
|
||||||
|
}
|
||||||
|
commit["id"] = _commit_id(commit)
|
||||||
|
doc["commits"] = list(doc.get("commits") or []) + [commit]
|
||||||
|
doc["head"] = commit["id"]
|
||||||
|
doc["snapshot"] = snapshot
|
||||||
|
return doc
|
||||||
|
|
@ -33,7 +33,14 @@ class NatsLedger:
|
||||||
scope: str = "local",
|
scope: str = "local",
|
||||||
cube_id: str = "",
|
cube_id: str = "",
|
||||||
org_id: str = "",
|
org_id: str = "",
|
||||||
|
*,
|
||||||
|
metadata: dict | None = None,
|
||||||
|
restamp: bool = False,
|
||||||
|
message: str = "",
|
||||||
) -> list[Receipt]:
|
) -> list[Receipt]:
|
||||||
|
if metadata or restamp:
|
||||||
|
raise ValueError("metadata history is stored on global stamps")
|
||||||
|
del message
|
||||||
digest = validate_sha256(sha256)
|
digest = validate_sha256(sha256)
|
||||||
body = await self._ask(
|
body = await self._ask(
|
||||||
SUBJECT_REQUEST,
|
SUBJECT_REQUEST,
|
||||||
|
|
|
||||||
|
|
@ -138,13 +138,25 @@ def _tool_error(exc: ValueError) -> ToolError:
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
async def stamp(
|
async def stamp(
|
||||||
sha256: str, scope: str = "local", cube_id: str = "", org_id: str = ""
|
sha256: str,
|
||||||
|
scope: str = "local",
|
||||||
|
cube_id: str = "",
|
||||||
|
org_id: str = "",
|
||||||
|
metadata: dict | None = None,
|
||||||
|
restamp: bool = False,
|
||||||
|
message: str = "",
|
||||||
) -> Receipts:
|
) -> Receipts:
|
||||||
"""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."""
|
"""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."""
|
||||||
try:
|
try:
|
||||||
with _caller_token():
|
with _caller_token():
|
||||||
receipts = await _ledger.stamp(
|
receipts = await _ledger.stamp(
|
||||||
sha256, scope=scope, cube_id=cube_id, org_id=org_id
|
sha256,
|
||||||
|
scope=scope,
|
||||||
|
cube_id=cube_id,
|
||||||
|
org_id=org_id,
|
||||||
|
metadata=metadata,
|
||||||
|
restamp=restamp,
|
||||||
|
message=message,
|
||||||
)
|
)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise _tool_error(exc) from exc
|
raise _tool_error(exc) from exc
|
||||||
|
|
|
||||||
|
|
@ -380,3 +380,36 @@ async def test_global_hash_report_uses_api_hash():
|
||||||
assert methods_urls[0] == ("GET", f"https://api.veraetime.net/api/hash/{SHA256_EMPTY}")
|
assert methods_urls[0] == ("GET", f"https://api.veraetime.net/api/hash/{SHA256_EMPTY}")
|
||||||
assert methods_urls[1][0] == "POST"
|
assert methods_urls[1][0] == "POST"
|
||||||
assert methods_urls[1][1].endswith("/api/verify")
|
assert methods_urls[1][1].endswith("/api/verify")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_restamp_sends_verae_meta_git_history():
|
||||||
|
class HistoryHttp(FakeHttp):
|
||||||
|
def request(self, method, url, payload, headers):
|
||||||
|
self.calls.append((method, url, payload, headers))
|
||||||
|
if url.endswith(f"/api/hash/{SHA256_EMPTY}"):
|
||||||
|
return _Resp(200, {"receipts": [{"jobId": "job-old", "certificate": "QmOld"}]})
|
||||||
|
if url.endswith("/api/status/job-old"):
|
||||||
|
return _Resp(200, {"status": "completed", "metadata": {"title": "deed", "tags": ["a"]}})
|
||||||
|
if url.endswith("/api/timestamp"):
|
||||||
|
return _Resp(202, {"jobId": "job-new"})
|
||||||
|
if url.endswith("/api/status/job-new"):
|
||||||
|
return _Resp(200, {"status": "completed", "result": "QmNew", "metadata": {"schema": "verae-meta-git/1"}})
|
||||||
|
raise AssertionError(f"unexpected {method} {url}")
|
||||||
|
|
||||||
|
http = HistoryHttp()
|
||||||
|
client = GlobalClient("https://api.veraetime.net", token="secret", http=http)
|
||||||
|
rec = client.submit(
|
||||||
|
SHA256_EMPTY,
|
||||||
|
metadata={"place": "office"},
|
||||||
|
restamp=True,
|
||||||
|
message="add the place",
|
||||||
|
at="2026-10-11T02:00:00.000Z",
|
||||||
|
)
|
||||||
|
post = next(call for call in http.calls if call[1].endswith("/api/timestamp"))
|
||||||
|
body = post[2]
|
||||||
|
assert body["restamp"] is True
|
||||||
|
assert body["metadata"]["schema"] == "verae-meta-git/1"
|
||||||
|
assert body["metadata"]["snapshot"]["title"] == "deed"
|
||||||
|
assert body["metadata"]["snapshot"]["place"] == "office"
|
||||||
|
assert body["metadata"]["snapshot"]["tags"] == ["a"]
|
||||||
|
assert rec.receipt_id == "QmNew"
|
||||||
|
|
|
||||||
59
tests/test_metadata_history.py
Normal file
59
tests/test_metadata_history.py
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
"""Golden verae-meta-git/1 ids shared with node-red-contrib-verae."""
|
||||||
|
|
||||||
|
from verae_mcp_timestamping_server.metadata_history import append_metadata
|
||||||
|
|
||||||
|
HEAD_1 = "161ff3344ba776ae7201b73914c0e7500116ac9dde02e1967a761b7089c41fa7"
|
||||||
|
HEAD_2 = "c606e41ccbf4c0f9f653b21759df83ecda25262621075db3a153b592270fd918"
|
||||||
|
|
||||||
|
|
||||||
|
def test_append_matches_node_reference():
|
||||||
|
first = append_metadata(
|
||||||
|
None,
|
||||||
|
{"title": "deed", "tags": ["a"]},
|
||||||
|
restamp=False,
|
||||||
|
at="2026-10-11T01:00:00.000Z",
|
||||||
|
message="stamp",
|
||||||
|
)
|
||||||
|
assert first["head"] == HEAD_1
|
||||||
|
second = append_metadata(
|
||||||
|
first,
|
||||||
|
{"title": "deed-2", "tags": ["b"], "place": "office"},
|
||||||
|
restamp=True,
|
||||||
|
at="2026-10-11T02:00:00.000Z",
|
||||||
|
message="correct the title",
|
||||||
|
)
|
||||||
|
assert second["head"] == HEAD_2
|
||||||
|
assert second["snapshot"] == {"title": "deed-2", "tags": ["a", "b"], "place": "office"}
|
||||||
|
assert second["commits"][1]["parent"] == HEAD_1
|
||||||
|
assert "removed" not in second["commits"][1]["changes"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_blank_restamp_keeps_snapshot():
|
||||||
|
nxt = append_metadata(
|
||||||
|
{"title": "deed", "note": "keep", "tags": ["a"]},
|
||||||
|
{"title": "", "note": None, "tags": []},
|
||||||
|
restamp=True,
|
||||||
|
at="2026-10-11T01:47:00.000Z",
|
||||||
|
prior_at="2026-10-11T01:00:00.000Z",
|
||||||
|
)
|
||||||
|
assert nxt["snapshot"]["title"] == "deed"
|
||||||
|
assert nxt["snapshot"]["note"] == "keep"
|
||||||
|
assert nxt["snapshot"]["tags"] == ["a"]
|
||||||
|
assert len(nxt["commits"]) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_omit_when_empty():
|
||||||
|
assert append_metadata(None, None, restamp=False, at="2026-10-11T01:00:00.000Z") is None
|
||||||
|
assert append_metadata(None, {}, restamp=False, at="2026-10-11T01:00:00.000Z") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_history_document_is_not_rewritten_by_append_of_itself():
|
||||||
|
first = append_metadata(
|
||||||
|
None,
|
||||||
|
{"title": "deed", "tags": ["a"]},
|
||||||
|
restamp=False,
|
||||||
|
at="2026-10-11T01:00:00.000Z",
|
||||||
|
message="stamp",
|
||||||
|
)
|
||||||
|
assert first["schema"] == "verae-meta-git/1"
|
||||||
|
assert first["commits"][0]["parent"] is None
|
||||||
Loading…
Reference in a new issue