Forward stamp metadata as a verae-meta-git/1 history.

A plain patch or restamp becomes a history document. A body that is already verae-meta-git/1 is sent unchanged. Calls that omit metadata and leave restamp false stay on the previous body.
This commit is contained in:
Cursor Agent 2026-10-11 05:37:25 +00:00
commit 014aeaf6c2
No known key found for this signature in database
3 changed files with 349 additions and 1 deletions

View file

@ -8,6 +8,7 @@ import { config } from '../config.js';
import { AppError } from '../errors.js';
import { createDebugger } from '../debug/logger.js';
import { stableVeraeUserId } from '../lib/identity.js';
import { isHistory, stampHistory } from '../metadata-history.js';
const log = createDebugger('http');
@ -220,6 +221,32 @@ async function request(path, { method = 'GET', token, body } = {}) {
return payload;
}
async function priorUserMetadata(token, digest) {
if (!digest) return null;
try {
const report = await request(`/api/hash/${encodeURIComponent(digest)}`, { token });
const receipts = Array.isArray(report?.receipts) ? report.receipts : [];
let jobId = '';
for (const ref of receipts) {
if (ref?.jobId) jobId = String(ref.jobId);
}
if (!jobId) return null;
const status = await request(`/api/status/${encodeURIComponent(jobId)}`, { token });
return status?.metadata && typeof status.metadata === 'object' ? status.metadata : null;
} catch (err) {
if (err?.status === 404) return null;
throw err;
}
}
async function historyBody(token, body, digest) {
let prior = null;
if (body?.restamp === true && !isHistory(body?.metadata)) {
prior = await priorUserMetadata(token, digest);
}
return stampHistory(body, prior);
}
/**
* Verae API client (mock when config.mockVerae is true).
*/
@ -269,6 +296,12 @@ export const veraeClient = {
async createTimestamp(token, body) {
if (config.mockVerae) return mockCreateTimestamp(body);
const liveBody = { data: body.data, hashAlg: body.hashAlg };
const digest = String(body.sha256 || body.digest || body.data || '').toLowerCase().replace(/^sha256:/, '');
const hist = await historyBody(token, body, /^[a-f0-9]{64}$/.test(digest) ? digest : '');
if (hist) {
liveBody.metadata = hist.metadata;
liveBody.restamp = hist.restamp;
}
return request('/api/timestamp', { method: 'POST', token, body: liveBody });
},
@ -297,7 +330,20 @@ export const veraeClient = {
}
return { jobIds };
}
return request('/api/batch/timestamp', { method: 'POST', token, body });
const items = [];
for (const item of body.items ?? []) {
const digest = String(item.sha256 || item.digest || item.data || '').toLowerCase().replace(/^sha256:/, '');
const next = { ...item };
const hist = await historyBody(token, item, /^[a-f0-9]{64}$/.test(digest) ? digest : '');
if (hist) {
next.metadata = hist.metadata;
next.restamp = hist.restamp;
} else {
delete next.metadata;
}
items.push(next);
}
return request('/api/batch/timestamp', { method: 'POST', token, body: { ...body, items } });
},
/**

192
src/metadata-history.js Normal file
View file

@ -0,0 +1,192 @@
import { createHash } from "node:crypto";
export const SCHEMA = "verae-meta-git/1";
export function isPlainObject(value) {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
export function isBlank(value) {
if (value == null) return true;
if (typeof value === "string") return value.trim() === "";
if (Array.isArray(value)) return value.length === 0;
if (isPlainObject(value)) return Object.keys(value).length === 0;
return false;
}
export function clone(value) {
if (Array.isArray(value)) return value.map(clone);
if (isPlainObject(value)) {
const copy = {};
for (const key of Object.keys(value)) copy[key] = clone(value[key]);
return copy;
}
return value;
}
export function canonical(value) {
if (Array.isArray(value)) return "[" + value.map(canonical).join(",") + "]";
if (isPlainObject(value)) {
const keys = Object.keys(value).sort();
return "{" + keys.map((key) => JSON.stringify(key) + ":" + canonical(value[key])).join(",") + "}";
}
return JSON.stringify(value);
}
export function same(a, b) {
return canonical(a) === canonical(b);
}
export function extendSnapshot(base, patch) {
const out = clone(isPlainObject(base) ? base : {});
if (!isPlainObject(patch)) return out;
for (const key of Object.keys(patch)) {
const next = patch[key];
if (isBlank(next)) continue;
const prev = out[key];
if (isPlainObject(prev) && isPlainObject(next)) {
out[key] = extendSnapshot(prev, next);
} else if (Array.isArray(prev) && Array.isArray(next)) {
const extra = next.filter((item) => !isBlank(item));
if (extra.length) out[key] = prev.concat(clone(extra));
} else if (Array.isArray(next)) {
const extra = next.filter((item) => !isBlank(item));
if (!extra.length) continue;
out[key] = Array.isArray(prev) ? prev.concat(clone(extra)) : clone(extra);
} else if (isPlainObject(next)) {
const nested = extendSnapshot(isPlainObject(prev) ? prev : {}, next);
if (!isBlank(nested)) out[key] = nested;
} else {
out[key] = clone(next);
}
}
return out;
}
export function changesBetween(before, after) {
const added = {};
const extended = {};
const updated = {};
const prior = isPlainObject(before) ? before : {};
const next = isPlainObject(after) ? after : {};
const keys = new Set([...Object.keys(prior), ...Object.keys(next)]);
for (const key of keys) {
if (!Object.prototype.hasOwnProperty.call(next, key)) continue;
if (!Object.prototype.hasOwnProperty.call(prior, key)) {
added[key] = clone(next[key]);
continue;
}
if (same(prior[key], next[key])) continue;
if (isPlainObject(prior[key]) && isPlainObject(next[key])) {
const nested = changesBetween(prior[key], next[key]);
if (!isBlank(nested.added) || !isBlank(nested.extended) || !isBlank(nested.updated)) {
extended[key] = nested;
}
} else if (Array.isArray(prior[key]) && Array.isArray(next[key]) && next[key].length >= prior[key].length) {
const appended = next[key].slice(prior[key].length);
if (appended.length) extended[key] = { appended: clone(appended) };
} else {
updated[key] = { from: clone(prior[key]), to: clone(next[key]) };
}
}
return { added, extended, updated };
}
export function commitId(commit) {
const material = canonical({
parent: commit.parent,
at: commit.at,
message: commit.message,
snapshot: commit.snapshot,
});
return createHash("sha256").update(material).digest("hex");
}
export function isHistory(value) {
return isPlainObject(value)
&& value.schema === SCHEMA
&& Array.isArray(value.commits)
&& isPlainObject(value.snapshot);
}
export function historyFromSnapshot(snapshot, at) {
const stored = clone(snapshot);
const commit = {
parent: null,
at,
message: "stamp",
snapshot: stored,
changes: changesBetween({}, stored),
};
commit.id = commitId(commit);
return {
schema: SCHEMA,
head: commit.id,
snapshot: clone(stored),
commits: [commit],
};
}
export function coerceHistory(existing, at) {
if (isHistory(existing)) return clone(existing);
if (isPlainObject(existing) && !isBlank(existing)) return historyFromSnapshot(existing, at);
return {
schema: SCHEMA,
head: null,
snapshot: {},
commits: [],
};
}
export function hasContent(patch) {
return isPlainObject(patch) && !isBlank(extendSnapshot({}, patch));
}
export function hasChanges(changes) {
return !isBlank(changes.added) || !isBlank(changes.extended) || !isBlank(changes.updated);
}
export function appendMetadata(existing, patch, options) {
const opts = options || {};
const restamp = Boolean(opts.restamp);
const at = opts.at || new Date().toISOString();
const blankPatch = !isPlainObject(patch) || isBlank(patch) || isBlank(extendSnapshot({}, patch));
if (!restamp && blankPatch && !isHistory(existing) && (existing == null || isBlank(existing))) {
return null;
}
const doc = coerceHistory(existing, opts.priorAt || at);
if (!restamp && blankPatch) return doc.head ? doc : null;
const snapshot = extendSnapshot(doc.snapshot, patch);
const changes = changesBetween(doc.snapshot, snapshot);
if (!restamp && !hasChanges(changes) && doc.head) return doc;
const commit = {
parent: doc.head,
at,
message: opts.message || (doc.head ? "restamp" : "stamp"),
snapshot,
changes,
};
commit.id = commitId(commit);
doc.commits.push(commit);
doc.head = commit.id;
doc.snapshot = snapshot;
return doc;
}
export function stampHistory(body, prior) {
const restamp = body?.restamp === true;
let metadata = body?.metadata ?? null;
if ((metadata == null || isBlank(metadata)) && body?.documentName && String(body.documentName).trim()) {
metadata = { title: String(body.documentName).trim() };
}
if ((metadata == null || isBlank(metadata)) && !restamp) return null;
if (isHistory(metadata)) return { metadata, restamp: restamp || (metadata.commits || []).length > 1 };
const doc = appendMetadata(restamp ? prior : null, metadata, {
restamp,
message: body?.message,
at: body?.at,
});
if (!doc) return null;
return { metadata: doc, restamp: restamp || (doc.commits || []).length > 1 };
}

View file

@ -0,0 +1,110 @@
import { describe, it, afterEach } from 'node:test';
import assert from 'node:assert/strict';
import { appendMetadata, stampHistory } from '../../src/metadata-history.js';
import { veraeClient } from '../../src/clients/veraeClient.js';
import { config } from '../../src/config.js';
const HEAD_1 = '161ff3344ba776ae7201b73914c0e7500116ac9dde02e1967a761b7089c41fa7';
const HEAD_2 = 'c606e41ccbf4c0f9f653b21759df83ecda25262621075db3a153b592270fd918';
const DIGEST = 'ab'.repeat(32);
describe('stampHistory', () => {
it('matches the node-red golden commit ids', () => {
const first = appendMetadata(null, { title: 'deed', tags: ['a'] }, {
restamp: false,
at: '2026-10-11T01:00:00.000Z',
message: 'stamp',
});
assert.equal(first.head, HEAD_1);
const second = stampHistory({
metadata: { title: 'deed-2', tags: ['b'], place: 'office' },
restamp: true,
message: 'correct the title',
at: '2026-10-11T02:00:00.000Z',
}, first);
assert.equal(second.restamp, true);
assert.equal(second.metadata.head, HEAD_2);
assert.deepEqual(second.metadata.snapshot, {
title: 'deed-2',
tags: ['a', 'b'],
place: 'office',
});
assert.equal(second.metadata.commits[1].parent, HEAD_1);
assert.equal(second.metadata.commits[1].changes.removed, undefined);
});
it('stores a history document as sent', () => {
const first = appendMetadata(null, { title: 'deed', tags: ['a'] }, {
at: '2026-10-11T01:00:00.000Z',
message: 'stamp',
});
const sent = stampHistory({ metadata: first, restamp: false }, null);
assert.equal(sent.metadata, first);
assert.equal(sent.restamp, false);
});
it('omits metadata when the body has none', () => {
assert.equal(stampHistory({ data: 'hello', restamp: false }, null), null);
});
it('uses a non-blank document name as the title', () => {
const sent = stampHistory({
documentName: 'Deed.pdf',
at: '2026-10-11T01:00:00.000Z',
}, null);
assert.equal(sent.metadata.schema, 'verae-meta-git/1');
assert.equal(sent.metadata.snapshot.title, 'Deed.pdf');
assert.equal(sent.restamp, false);
});
it('does not delete fields when the patch is blank', () => {
const sent = stampHistory({
metadata: { title: '', tags: [] },
restamp: true,
at: '2026-10-11T03:00:00.000Z',
}, { title: 'deed', tags: ['a'] });
assert.equal(sent.metadata.snapshot.title, 'deed');
assert.deepEqual(sent.metadata.snapshot.tags, ['a']);
assert.equal(sent.metadata.commits.length, 2);
});
});
describe('createTimestamp history body', { skip: config.mockVerae }, () => {
const original = globalThis.fetch;
afterEach(() => {
globalThis.fetch = original;
});
it('leaves a digest-only body unchanged', async () => {
const calls = [];
globalThis.fetch = async (url, init) => {
calls.push({ url: String(url), init });
return new Response(JSON.stringify({ jobId: 'job-1' }), { status: 202 });
};
await veraeClient.createTimestamp('tok', { data: DIGEST, hashAlg: 'SHA256' });
const body = JSON.parse(calls[0].init.body);
assert.equal(Object.hasOwn(body, 'metadata'), false);
assert.notEqual(body.restamp, true);
});
it('sends a title history for documentName', async () => {
const calls = [];
globalThis.fetch = async (url, init) => {
calls.push({ url: String(url), init });
return new Response(JSON.stringify({ jobId: 'job-1' }), { status: 202 });
};
await veraeClient.createTimestamp('tok', {
data: DIGEST,
sha256: DIGEST,
hashAlg: 'SHA256',
documentName: 'Deed.pdf',
at: '2026-10-11T01:00:00.000Z',
});
const body = JSON.parse(calls[0].init.body);
assert.equal(body.metadata.schema, 'verae-meta-git/1');
assert.equal(body.metadata.snapshot.title, 'Deed.pdf');
assert.equal(body.restamp, false);
assert.equal(calls.length, 1);
});
});