From 394c46ebfcb6dd7953e012d775375e9250273367 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 11 Oct 2026 05:37:25 +0000 Subject: [PATCH] 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. --- site/openapi/application-client.yaml | 26 ++- .../global_client.py | 95 +++++++- src/verae_mcp_timestamping_server/ledger.py | 45 +++- .../metadata_history.py | 207 ++++++++++++++++++ .../nats_ledger.py | 7 + src/verae_mcp_timestamping_server/server.py | 18 +- tests/test_global_client.py | 33 +++ tests/test_metadata_history.py | 59 +++++ 8 files changed, 469 insertions(+), 21 deletions(-) create mode 100644 src/verae_mcp_timestamping_server/metadata_history.py create mode 100644 tests/test_metadata_history.py diff --git a/site/openapi/application-client.yaml b/site/openapi/application-client.yaml index 4202f40..974d828 100644 --- a/site/openapi/application-client.yaml +++ b/site/openapi/application-client.yaml @@ -14,8 +14,9 @@ info: arguments named `content`, `bytes`, `file`, `data`, `path`, `body`, `payload`, `blob`, or `archive`. - **Global stamp:** VeraeTime body uses `restamp: false` by default (reuse existing - certificate). Explicit restamp is out of scope unless added later. + **Global stamp:** VeraeTime body uses `restamp: false` and omits metadata unless + 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) @@ -52,8 +53,9 @@ paths: summary: Stamp a SHA-256 digest description: | Request a timestamp for a digest. Scope `local` or `shared` goes to MCP tool - `stamp`; scope `global` goes to VeraeTime `POST /api/timestamp` with - `restamp: false` by default. + `stamp`; scope `global` goes to VeraeTime `POST /api/timestamp`. The default + body is digest-only with `restamp: false`. `metadata` or `restamp` sends a + `verae-meta-git/1` history. tags: [timestamping] requestBody: required: true @@ -250,13 +252,27 @@ components: minLength: 64 scope: type: string - description: Default local. global → VeraeTime with restamp false. + description: Default local. global → VeraeTime. Metadata history is global only. enum: [local, shared, global] default: local cube_id: type: string org_id: 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: type: object required: [receipts] diff --git a/src/verae_mcp_timestamping_server/global_client.py b/src/verae_mcp_timestamping_server/global_client.py index 5d2ae81..f1a08b3 100644 --- a/src/verae_mcp_timestamping_server/global_client.py +++ b/src/verae_mcp_timestamping_server/global_client.py @@ -360,7 +360,16 @@ class GlobalClient: headers["Authorization"] = f"Bearer {self.token}" 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() saved = self.token if override: @@ -368,22 +377,75 @@ class GlobalClient: else: self.ensure_token() try: - return self._submit(digest, cube_id) + return self._submit(digest, cube_id, metadata=metadata, restamp=restamp, message=message, at=at) finally: if override: 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" + payload = { + "data": digest, + "digest": digest, + "hashAlg": "SHA256", + "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={ - "data": digest, - "digest": digest, - "hashAlg": "SHA256", - "restamp": False, - }, + payload=payload, headers=self._headers(), ) parsed = res.json() if hasattr(res, "json") else {} @@ -394,6 +456,8 @@ class GlobalClient: if res.status_code == 200 and ( 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 "") if cid: return Receipt( @@ -560,10 +624,21 @@ class GlobalLedger: scope: str = "global", cube_id: str = "", org_id: str = "", + *, + metadata: dict | None = None, + restamp: bool = False, + message: str = "", ) -> list[Receipt]: del org_id 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] async def verify(self, receipt_id: str, want_scope: str = "") -> VerifyResult: diff --git a/src/verae_mcp_timestamping_server/ledger.py b/src/verae_mcp_timestamping_server/ledger.py index 8cfab22..e82e261 100644 --- a/src/verae_mcp_timestamping_server/ledger.py +++ b/src/verae_mcp_timestamping_server/ledger.py @@ -45,6 +45,10 @@ class Ledger(Protocol): scope: str = "local", cube_id: str = "", org_id: str = "", + *, + metadata: dict | None = None, + restamp: bool = False, + message: str = "", ) -> list[Receipt]: ... async def verify(self, receipt_id: str, want_scope: str = "") -> VerifyResult: ... @@ -92,7 +96,14 @@ class MemoryLedger: scope: str = "local", cube_id: str = "", org_id: str = "", + *, + metadata: dict | None = None, + restamp: bool = False, + message: str = "", ) -> list[Receipt]: + del message + if metadata or restamp: + raise ValueError("metadata history is stored on global stamps") digest = validate_sha256(sha256) if scope not in _SCOPE_RANK: raise ValueError("scope must be local, shared, or global") @@ -192,9 +203,21 @@ class FileLedger(MemoryLedger): scope: str = "local", cube_id: str = "", org_id: str = "", + *, + metadata: dict | None = None, + restamp: bool = False, + message: str = "", ) -> list[Receipt]: 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: self._save() return receipts @@ -217,13 +240,29 @@ class RoutingLedger: scope: str = "local", cube_id: str = "", org_id: str = "", + *, + metadata: dict | None = None, + restamp: bool = False, + message: str = "", ) -> list[Receipt]: if scope == "global": 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( - 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: diff --git a/src/verae_mcp_timestamping_server/metadata_history.py b/src/verae_mcp_timestamping_server/metadata_history.py new file mode 100644 index 0000000..d8d73d4 --- /dev/null +++ b/src/verae_mcp_timestamping_server/metadata_history.py @@ -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 diff --git a/src/verae_mcp_timestamping_server/nats_ledger.py b/src/verae_mcp_timestamping_server/nats_ledger.py index f2db2d6..37a1ff2 100644 --- a/src/verae_mcp_timestamping_server/nats_ledger.py +++ b/src/verae_mcp_timestamping_server/nats_ledger.py @@ -33,7 +33,14 @@ class NatsLedger: scope: str = "local", cube_id: str = "", org_id: str = "", + *, + metadata: dict | None = None, + restamp: bool = False, + message: str = "", ) -> list[Receipt]: + if metadata or restamp: + raise ValueError("metadata history is stored on global stamps") + del message digest = validate_sha256(sha256) body = await self._ask( SUBJECT_REQUEST, diff --git a/src/verae_mcp_timestamping_server/server.py b/src/verae_mcp_timestamping_server/server.py index 2a486fa..6c07574 100644 --- a/src/verae_mcp_timestamping_server/server.py +++ b/src/verae_mcp_timestamping_server/server.py @@ -138,13 +138,25 @@ def _tool_error(exc: ValueError) -> ToolError: @mcp.tool() 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: - """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: with _caller_token(): 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: raise _tool_error(exc) from exc diff --git a/tests/test_global_client.py b/tests/test_global_client.py index e3f2cdb..384a37c 100644 --- a/tests/test_global_client.py +++ b/tests/test_global_client.py @@ -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[1][0] == "POST" 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" diff --git a/tests/test_metadata_history.py b/tests/test_metadata_history.py new file mode 100644 index 0000000..aa44765 --- /dev/null +++ b/tests/test_metadata_history.py @@ -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