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:
parent
fa1220e808
commit
014aeaf6c2
3 changed files with 349 additions and 1 deletions
|
|
@ -8,6 +8,7 @@ import { config } from '../config.js';
|
||||||
import { AppError } from '../errors.js';
|
import { AppError } from '../errors.js';
|
||||||
import { createDebugger } from '../debug/logger.js';
|
import { createDebugger } from '../debug/logger.js';
|
||||||
import { stableVeraeUserId } from '../lib/identity.js';
|
import { stableVeraeUserId } from '../lib/identity.js';
|
||||||
|
import { isHistory, stampHistory } from '../metadata-history.js';
|
||||||
|
|
||||||
const log = createDebugger('http');
|
const log = createDebugger('http');
|
||||||
|
|
||||||
|
|
@ -220,6 +221,32 @@ async function request(path, { method = 'GET', token, body } = {}) {
|
||||||
return payload;
|
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).
|
* Verae API client (mock when config.mockVerae is true).
|
||||||
*/
|
*/
|
||||||
|
|
@ -269,6 +296,12 @@ export const veraeClient = {
|
||||||
async createTimestamp(token, body) {
|
async createTimestamp(token, body) {
|
||||||
if (config.mockVerae) return mockCreateTimestamp(body);
|
if (config.mockVerae) return mockCreateTimestamp(body);
|
||||||
const liveBody = { data: body.data, hashAlg: body.hashAlg };
|
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 });
|
return request('/api/timestamp', { method: 'POST', token, body: liveBody });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -297,7 +330,20 @@ export const veraeClient = {
|
||||||
}
|
}
|
||||||
return { jobIds };
|
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
192
src/metadata-history.js
Normal 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 };
|
||||||
|
}
|
||||||
|
|
||||||
110
test/unit/metadata-history.test.js
Normal file
110
test/unit/metadata-history.test.js
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue