diff --git a/src/clients/veraeClient.js b/src/clients/veraeClient.js index 31aff54..781799c 100644 --- a/src/clients/veraeClient.js +++ b/src/clients/veraeClient.js @@ -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 } }); }, /** diff --git a/src/metadata-history.js b/src/metadata-history.js new file mode 100644 index 0000000..df2d8a7 --- /dev/null +++ b/src/metadata-history.js @@ -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 }; +} + diff --git a/test/unit/metadata-history.test.js b/test/unit/metadata-history.test.js new file mode 100644 index 0000000..870300f --- /dev/null +++ b/test/unit/metadata-history.test.js @@ -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); + }); +});