Files
jlinc-server/backend/modules/dashboard/verify.js
2026-08-20 15:08:32 +00:00

147 lines
5.2 KiB
JavaScript

import { getPool } from "../../db/index.js";
// Lazy import so this module does not pull in @jlinc/core.
const defaultVerify = async (input, userId) =>
(await import("../core/data/index.js")).data.audit.verify(input, userId);
// A target is ONE ROW: { eventUuid } covers all of an event's audit records,
// { auditId } covers exactly one. Audit rows share an eventUuid, so keying them
// by event reports one record's verdict on all its siblings.
const MAX_BATCH = 200;
// Joined through `event` on user_id: an audit id is a lookup key, never an
// authorization decision.
async function fetchAuditRecords(client, userId, { eventUuid, auditId }) {
const byAuditId = auditId != null;
const rows = (
await client.query(
`SELECT a.audit_id, a.version, a.event_id, a.hash_type, a.digest, a.created,
e.sender_id, e.recipient_id,
COALESCE(
JSON_AGG(
JSON_BUILD_OBJECT('version', s.version, 'id', s.id, 'signedon', s.signedon, 'type', s.type, 'jws', s.jws)
) FILTER (WHERE s.audit_id IS NOT NULL),
'[]'
) AS signatures
FROM audit a
JOIN event e ON e.event_id_uuid = a.event_id AND e.user_id = $1
LEFT JOIN audit_signature s ON s.audit_id = a.audit_id
WHERE ${byAuditId ? "a.audit_id = $2" : "a.event_id = $2"}
GROUP BY a.audit_id, a.version, a.event_id, a.hash_type, a.digest, a.created,
e.sender_id, e.recipient_id
ORDER BY a.audit_id
LIMIT $3`,
[userId, byAuditId ? auditId : eventUuid, byAuditId ? 1 : MAX_BATCH],
)
).rows;
const dids = new Set();
const records = rows.map((row) => {
if (row.sender_id) dids.add(row.sender_id);
if (row.recipient_id) dids.add(row.recipient_id);
return {
auditId: Number(row.audit_id),
audit: {
version: row.version,
hashType: row.hash_type,
digest: row.digest,
created: Number(row.created),
eventId: row.event_id,
},
signatures: (row.signatures || []).map((r) => ({
version: r.version,
id: r.id,
signedOn: Number(r.signedon),
type: r.type,
jws: r.jws,
})),
};
});
return { records, dids: [...dids] };
}
// Only the DIDs the batch actually references, and only did_doc — getEntity
// would return the control and recovery private keys, which verification never
// needs. Resolved once per batch and memoised across targets.
async function resolveDidDocs(client, userId, dids, cache) {
const missing = dids.filter((did) => !cache.has(did));
if (missing.length > 0) {
const rows = (
await client.query(
`SELECT did_id, did_doc FROM entity WHERE user_id = $1 AND did_id = ANY($2)`,
[userId, missing],
)
).rows;
for (const did of missing) cache.set(did, null);
for (const row of rows) cache.set(row.did_id, row.did_doc);
}
return dids.map((did) => cache.get(did)).filter(Boolean);
}
export function summarizeVerification(target, records, verifierResult) {
const { eventUuid = null, auditId = null } = target;
if (records.length === 0) {
return {
eventUuid,
auditId,
status: "no-audit",
verified: false,
signatureCount: 0,
auditCount: 0,
checks: null,
reason: "no audit record",
};
}
const valid = verifierResult?.data?.valid || [];
const invalid = verifierResult?.data?.invalid || [];
// Matched by audit id: the verifier splits input across two buckets, so
// position is meaningless once a target has several records.
const verdicts = records.map((r) => ({
verified: valid.some((e) => e.audit?.auditId === r.auditId),
checks: [...valid, ...invalid].find((e) => e.audit?.auditId === r.auditId)?.results || null,
}));
const failed = verdicts.find((v) => !v.verified);
return {
eventUuid,
auditId,
status: failed ? "invalid" : "verified",
verified: !failed,
signatureCount: records.reduce((n, r) => n + r.signatures.length, 0),
auditCount: records.length,
checks: (failed || verdicts[0]).checks,
};
}
async function verifyTarget(acquire, verify, userId, target, cache) {
const client = await acquire();
let records, didDocs;
try {
const fetched = await fetchAuditRecords(client, userId, target);
records = fetched.records;
didDocs = records.length === 0 ? [] : await resolveDidDocs(client, userId, fetched.dids, cache);
} finally {
await client.release();
}
if (records.length === 0) return summarizeVerification(target, records, null);
const result = await verify({ didDocs, audits: records }, userId);
return summarizeVerification(target, records, result);
}
export async function verifyMany(userId, { eventUuids = [], auditIds = [] } = {}, { acquire = getPool, verify = defaultVerify } = {}) {
const targets = [
...eventUuids.map((eventUuid) => ({ eventUuid, auditId: null })),
...auditIds.map((auditId) => ({ eventUuid: null, auditId })),
].slice(0, MAX_BATCH);
if (targets.length === 0) return [];
const cache = new Map();
const results = [];
for (const target of targets) {
results.push(await verifyTarget(acquire, verify, userId, target, cache));
}
return results;
}