addition of new dashboard

This commit is contained in:
2026-08-20 15:08:32 +00:00
parent 0622018d95
commit bf59249ed7
161 changed files with 13170 additions and 119 deletions

View File

@@ -0,0 +1,12 @@
import { getPool } from "../../db/index.js";
// Legacy `auth` table only; the user-managed api_key store is counted separately.
export async function countApiKeys(userId) {
const client = await getPool();
try {
const r = await client.query(`SELECT COUNT(*)::int AS n FROM auth WHERE user_id = $1`, [userId]);
return r.rows[0].n;
} finally {
await client.release();
}
}

View File

@@ -0,0 +1,50 @@
import { service as defaultService } from "./service.js";
import { parseWindow, parseTransactionsQuery, parseVerifyTargets, isUuid } from "./validate.js";
// `input` is the request body on the API-key path and the query string on the
// browser path; both are plain string maps, so one parser serves both.
// `status` is for the browser routes — the API-key router ignores it.
export function makeDashboardApi(service = defaultService) {
return {
async summary(input, userId) {
return { message: "dashboard summary", status: 200, data: await service.summary(userId, parseWindow(input)) };
},
async transactions(input, userId) {
const parsed = parseTransactionsQuery(input);
if (!parsed.ok) return { message: "invalid request", status: 400, data: { error: parsed.error } };
return { message: "dashboard transactions", status: 200, data: await service.transactions(userId, parsed.value) };
},
async series(input, userId) {
return { message: "dashboard series", status: 200, data: await service.series(userId, parseWindow(input)) };
},
async verify(input, userId) {
return { message: "dashboard verify", status: 200, data: await service.verify(userId, parseVerifyTargets(input)) };
},
async event(input, userId) {
if (!isUuid(input?.eventUuid)) return { message: "invalid request", status: 400, data: { error: "invalid event id" } };
const record = await service.event(userId, input.eventUuid);
if (!record) return { message: "dashboard event", status: 404, data: { error: "event not found" } };
return { message: "dashboard event", status: 200, data: record };
},
async agreement(input, userId) {
if (!isUuid(input?.agreementUuid)) return { message: "invalid request", status: 400, data: { error: "invalid agreement id" } };
const record = await service.agreement(userId, input.agreementUuid);
if (!record) return { message: "dashboard agreement", status: 404, data: { error: "agreement not found" } };
return { message: "dashboard agreement", status: 200, data: record };
},
async details(input, userId) {
if (!isUuid(input?.eventUuid)) return { message: "invalid request", status: 400, data: { error: "invalid event id" } };
const record = await service.details(userId, input.eventUuid);
if(!record) return { message: "dashboard event details", status: 404, data: { error: "event details not found" } };
return { message: "dashboard event details", status: 200, data: record };
},
};
}
export const dashboardApi = makeDashboardApi();

View File

@@ -0,0 +1,60 @@
import { jest } from "@jest/globals";
import { makeDashboardApi } from "./api.js";
const fakeService = () => ({
summary: jest.fn(async () => ({ apiUsage: { total: 3 } })),
transactions: jest.fn(async (_u, opts) => ({ rows: [], total: 0, limit: opts.limit, offset: opts.offset })),
series: jest.fn(async () => ({ usage: [], storage: [] })),
verify: jest.fn(async (_u, targets) => ({ results: targets })),
event: jest.fn(async () => ({ eventId: "x", data: { a: 1 } })),
});
describe("dashboard API adapter", () => {
it("summary wraps the service result with a message + window", async () => {
const service = fakeService();
const out = await makeDashboardApi(service).summary({ from: "2026-01-01", to: "2026-01-31" }, 7);
expect(out).toEqual({ message: "dashboard summary", status: 200, data: { apiUsage: { total: 3 } } });
expect(service.summary).toHaveBeenCalledWith(7, { from: "2026-01-01", to: "2026-01-31" });
});
it("transactions passes validated options through", async () => {
const service = fakeService();
const out = await makeDashboardApi(service).transactions({ type: "audit", limit: "5" }, 7);
expect(out.message).toBe("dashboard transactions");
expect(out.status).toBe(200);
expect(service.transactions).toHaveBeenCalledWith(7, expect.objectContaining({ type: "audit", limit: 5 }));
});
it("series wraps the combined usage+storage result with the window", async () => {
const service = fakeService();
const out = await makeDashboardApi(service).series({ from: "2026-01-01", to: "2026-01-31" }, 7);
expect(out).toEqual({ message: "dashboard series", status: 200, data: { usage: [], storage: [] } });
expect(service.series).toHaveBeenCalledWith(7, { from: "2026-01-01", to: "2026-01-31" });
});
it("transactions returns a 400 error payload (not a throw) on invalid input", async () => {
const service = fakeService();
const out = await makeDashboardApi(service).transactions({ type: "evil" }, 7);
expect(out.status).toBe(400);
expect(out.data.error).toBeTruthy();
expect(service.transactions).not.toHaveBeenCalled();
});
it("verify forwards valid event uuids and audit ids, dropping junk", async () => {
const service = fakeService();
const good = "3fa85f64-5717-4562-b3fc-2c963f66afa6";
const out = await makeDashboardApi(service).verify({ ids: `${good},junk`, auditIds: "9,nope" }, 7);
expect(out.data.results).toEqual({ eventUuids: [good], auditIds: [9] });
});
it("event rejects a non-uuid with 400 and 404s a missing record", async () => {
const service = fakeService();
const api = makeDashboardApi(service);
const bad = await api.event({ eventUuid: "nope" }, 7);
expect(bad).toMatchObject({ status: 400, data: { error: "invalid event id" } });
service.event = jest.fn(async () => null);
const missing = await api.event({ eventUuid: "3fa85f64-5717-4562-b3fc-2c963f66afa6" }, 7);
expect(missing).toMatchObject({ status: 404, data: { error: "event not found" } });
});
});

View File

@@ -0,0 +1,33 @@
import { getPool } from "../../db/index.js";
// Lockless: concurrent cold misses may both compute, last writer wins.
export async function getCached(userId, tag, ttlMs, compute) {
let client = await getPool();
try {
const hit = (
await client.query(
`SELECT data FROM cache
WHERE user_id = $1 AND tag = $2
AND updated_ts > NOW() - ($3 * interval '1 millisecond')`,
[userId, tag, ttlMs],
)
).rows[0];
if (hit) return hit.data;
} finally {
await client.release();
}
const data = await compute();
client = await getPool();
try {
await client.query(
`INSERT INTO cache (user_id, tag, data) VALUES ($1, $2, $3::jsonb)
ON CONFLICT (user_id, tag) DO UPDATE SET data = EXCLUDED.data, updated_ts = NOW()`,
[userId, tag, data],
);
} finally {
await client.release();
}
return data;
}

View File

@@ -0,0 +1,46 @@
import { jest } from "@jest/globals";
// Manual DB mock: getPool() returns a fake client whose query() the test drives.
const query = jest.fn();
const release = jest.fn(async () => {});
jest.unstable_mockModule("../../db/index.js", () => ({ getPool: async () => ({ query, release }) }));
const { getCached } = await import("./cache.js");
beforeEach(() => {
query.mockReset();
release.mockClear();
});
describe("getCached", () => {
it("returns the cached value on a fresh hit without computing", async () => {
query.mockResolvedValueOnce({ rows: [{ data: { v: 1 } }] });
const compute = jest.fn(async () => ({ v: 999 }));
const out = await getCached(7, "storage", 1000, compute);
expect(out).toEqual({ v: 1 });
expect(compute).not.toHaveBeenCalled();
expect(query).toHaveBeenCalledTimes(1); // SELECT only
expect(query.mock.calls[0][1]).toEqual([7, "storage", 1000]);
});
it("computes and upserts on a miss", async () => {
query.mockResolvedValueOnce({ rows: [] }).mockResolvedValueOnce({ rows: [] }); // SELECT miss, then INSERT
const compute = jest.fn(async () => ({ v: 2 }));
const out = await getCached(7, "storage", 1000, compute);
expect(out).toEqual({ v: 2 });
expect(compute).toHaveBeenCalledTimes(1);
expect(query).toHaveBeenCalledTimes(2); // SELECT then INSERT
const [sql, params] = query.mock.calls[1];
expect(sql).toContain("ON CONFLICT (user_id, tag) DO UPDATE");
expect(params).toEqual([7, "storage", { v: 2 }]);
});
it("does not cache when compute throws", async () => {
query.mockResolvedValueOnce({ rows: [] });
const compute = jest.fn(async () => { throw new Error("nope"); });
await expect(getCached(7, "storage", 1000, compute)).rejects.toThrow("nope");
expect(query).toHaveBeenCalledTimes(1); // SELECT only; no INSERT
});
});

View File

@@ -0,0 +1,17 @@
import { dashboardApi } from "./api.js";
const routes = {
'/api/v1/data/dashboard/summary': dashboardApi.summary,
'/api/v1/data/dashboard/transactions': dashboardApi.transactions,
'/api/v1/data/dashboard/series': dashboardApi.series,
'/api/v1/data/dashboard/verify': dashboardApi.verify,
'/api/v1/data/dashboard/event': dashboardApi.event,
};
export async function dispatch(url, input, userId) {
// hasOwn: a bare lookup resolves Object.prototype members to callables.
if (!Object.hasOwn(routes, url)) return null;
return routes[url](input, userId);
}
export const dashboard = { dispatch };

View File

@@ -0,0 +1,79 @@
// The only values ever interpolated into SQL (sort column and direction) come
// from the whitelists here; everything else is a bind parameter.
const SORT_COLUMNS = {
agreementUuid: '"agreementUuid"',
eventUuid: '"eventUuid"',
sender: "sender",
date: "date",
size: '"size"',
};
export const SORTABLE_KEYS = Object.keys(SORT_COLUMNS);
export function sortClause(sort, dir) {
const col = SORT_COLUMNS[sort] || "date";
const direction = String(dir).toLowerCase() === "asc" ? "ASC" : "DESC";
return `ORDER BY ${col} ${direction} NULLS LAST`;
}
export function eventSearchClause(idx) {
return (
`(e.event_id_uuid::text ILIKE '%'||$${idx}||'%'` +
` OR agr.agreement_id_uuid::text ILIKE '%'||$${idx}||'%'` +
` OR e.sender_id ILIKE '%'||$${idx}||'%'` +
` OR e.recipient_id ILIKE '%'||$${idx}||'%')`
);
}
export const DEFAULT_LIMIT = 50;
const MAX_LIMIT = 200;
// Server-side bound, applied on every request regardless of what the UI sends.
export function clampLimit(v) {
const n = parseInt(v, 10);
if (!Number.isFinite(n)) return DEFAULT_LIMIT;
return Math.min(Math.max(n, 1), MAX_LIMIT);
}
export function clampOffset(v) {
const n = parseInt(v, 10);
if (!Number.isFinite(n) || n < 0) return 0;
return n;
}
export function pageCount(total, perPage = DEFAULT_LIMIT) {
return Math.max(1, Math.ceil((Number(total) || 0) / (perPage || DEFAULT_LIMIT)));
}
const MAX_WINDOW_DAYS = 366;
// Enforced server-side on every request, not in the UI.
export function windowParams(query = {}) {
const iso = (d) => d.toISOString().slice(0, 10);
const valid = (s) => typeof s === "string" && /^\d{4}-\d{2}-\d{2}$/.test(s);
const today = new Date();
today.setUTCHours(0, 0, 0, 0);
const to = valid(query.to) ? query.to : iso(today);
let from = valid(query.from) ? query.from : null;
if (!from) {
const d = new Date(`${to}T00:00:00Z`);
d.setUTCDate(d.getUTCDate() - 29);
from = iso(d);
}
let fromD = new Date(`${from}T00:00:00Z`);
const toD = new Date(`${to}T00:00:00Z`);
if (fromD > toD) {
from = to;
fromD = new Date(toD);
}
if ((toD - fromD) / 86_400_000 > MAX_WINDOW_DAYS) {
const capped = new Date(toD);
capped.setUTCDate(capped.getUTCDate() - MAX_WINDOW_DAYS);
from = iso(capped);
}
return { from, to };
}

View File

@@ -0,0 +1,107 @@
import {
sortClause,
SORTABLE_KEYS,
eventSearchClause,
clampLimit,
clampOffset,
pageCount,
windowParams,
DEFAULT_LIMIT,
} from "./query.js";
describe("sortClause", () => {
it("maps known keys and directions", () => {
expect(sortClause("size", "asc")).toBe('ORDER BY "size" ASC NULLS LAST');
expect(sortClause("sender", "desc")).toBe("ORDER BY sender DESC NULLS LAST");
});
it("falls back to date DESC for unknown/missing keys", () => {
expect(sortClause(undefined, undefined)).toBe("ORDER BY date DESC NULLS LAST");
expect(sortClause("nope", "asc")).toBe("ORDER BY date ASC NULLS LAST");
});
it("never interpolates an attacker-supplied sort key (injection safety)", () => {
const clause = sortClause("date; DROP TABLE event; --", "asc");
expect(clause).toBe("ORDER BY date ASC NULLS LAST");
expect(clause).not.toMatch(/DROP TABLE/);
});
it("only honors exactly 'asc' for direction, otherwise DESC", () => {
expect(sortClause("date", "ASC")).toContain("ASC");
expect(sortClause("date", "ascending")).toContain("DESC");
expect(sortClause("date", "; DELETE")).toContain("DESC");
});
it("exposes the sortable keys", () => {
expect(SORTABLE_KEYS).toEqual(
expect.arrayContaining(["agreementUuid", "eventUuid", "sender", "date", "size"]),
);
});
});
describe("eventSearchClause", () => {
it("references the given bind index and only that index (no interpolated value)", () => {
const c = eventSearchClause(3);
expect(c).toContain("$3");
expect(c).toContain("ILIKE");
// no other positional params leak in
expect(c.match(/\$\d+/g).every((p) => p === "$3")).toBe(true);
});
});
describe("clampLimit / clampOffset", () => {
it("clamps limit into [1, 200] with a default", () => {
expect(clampLimit(undefined)).toBe(DEFAULT_LIMIT);
expect(clampLimit("abc")).toBe(DEFAULT_LIMIT);
expect(clampLimit("0")).toBe(1);
expect(clampLimit("-5")).toBe(1);
expect(clampLimit("9999")).toBe(200);
expect(clampLimit("75")).toBe(75);
});
it("clamps offset to a non-negative integer", () => {
expect(clampOffset(undefined)).toBe(0);
expect(clampOffset("-3")).toBe(0);
expect(clampOffset("abc")).toBe(0);
expect(clampOffset("40")).toBe(40);
});
});
describe("pageCount", () => {
it("computes ceil(total/perPage), at least 1", () => {
expect(pageCount(0, 50)).toBe(1);
expect(pageCount(50, 50)).toBe(1);
expect(pageCount(51, 50)).toBe(2);
expect(pageCount(200, 50)).toBe(4);
});
});
describe("windowParams", () => {
it("defaults to a 30-day window ending today (UTC)", () => {
const { from, to } = windowParams({});
const days = (new Date(`${to}T00:00:00Z`) - new Date(`${from}T00:00:00Z`)) / 86_400_000;
expect(days).toBe(29);
expect(to).toMatch(/^\d{4}-\d{2}-\d{2}$/);
});
it("honors valid from/to", () => {
expect(windowParams({ from: "2026-01-01", to: "2026-01-31" })).toEqual({
from: "2026-01-01",
to: "2026-01-31",
});
});
it("ignores invalid dates and collapses inverted ranges", () => {
expect(windowParams({ from: "not-a-date", to: "2026-02-10" }).to).toBe("2026-02-10");
expect(windowParams({ from: "2026-05-01", to: "2026-01-01" })).toEqual({
from: "2026-01-01",
to: "2026-01-01",
});
});
it("caps windows wider than ~a year", () => {
const { from, to } = windowParams({ from: "2000-01-01", to: "2026-01-01" });
const days = (new Date(`${to}T00:00:00Z`) - new Date(`${from}T00:00:00Z`)) / 86_400_000;
expect(days).toBeLessThanOrEqual(366);
});
});

View File

@@ -0,0 +1,61 @@
import { getPool } from "../../db/index.js";
// Usage counts are per-day; storage totals are running cumulatives.
export async function getSeries(userId, { from, to }) {
const client = await getPool();
try {
const res = await client.query(
`SELECT
to_char(d, 'YYYY-MM-DD') AS date,
(
SELECT COUNT(*)::int FROM usage u
JOIN usage_url uu ON uu.id = u.usage_url_id
WHERE u.user_id = $1 AND uu.module = 'core'
AND u.created_ts >= d AND u.created_ts < d + interval '1 day'
) AS core,
(
SELECT COUNT(*)::int FROM usage u
JOIN usage_url uu ON uu.id = u.usage_url_id
WHERE u.user_id = $1 AND uu.module = 'archive'
AND u.created_ts >= d AND u.created_ts < d + interval '1 day'
) AS audit,
(
SELECT COALESCE(SUM(pg_column_size(ed.data)), 0)::bigint
FROM event e
JOIN event_data ed ON ed.event_id = e.id
WHERE e.user_id = $1 AND e.created_as_ts < d + interval '1 day'
) AS core_disk,
(
(
SELECT COALESCE(SUM(pg_column_size(a.digest)), 0)::bigint
FROM audit a
JOIN event e ON e.event_id_uuid = a.event_id AND e.user_id = $1
WHERE a.created_ts < d + interval '1 day'
)
+
(
SELECT COALESCE(SUM(pg_column_size(s.jws)), 0)::bigint
FROM audit_signature s
JOIN audit a ON a.audit_id = s.audit_id
JOIN event e ON e.event_id_uuid = a.event_id AND e.user_id = $1
WHERE a.created_ts < d + interval '1 day'
)
) AS audit_disk
FROM generate_series($2::date, $3::date, interval '1 day') AS d
ORDER BY d`,
[userId, from, to],
);
const usage = [];
const storage = [];
for (const r of res.rows) {
const core = r.core, audit = r.audit;
usage.push({ date: r.date, core, audit, total: core + audit });
const coreDisk = Number(r.core_disk), auditDisk = Number(r.audit_disk);
storage.push({ date: r.date, coreDisk, auditDisk, totalDisk: coreDisk + auditDisk });
}
return { usage, storage };
} finally {
await client.release();
}
}

View File

@@ -0,0 +1,37 @@
import { jest } from "@jest/globals";
const query = jest.fn();
const release = jest.fn(async () => {});
jest.unstable_mockModule("../../db/index.js", () => ({ getPool: async () => ({ query, release }) }));
const { getSeries } = await import("./series.js");
beforeEach(() => {
query.mockReset();
release.mockClear();
});
describe("getSeries (combined usage + storage, one query)", () => {
it("splits one result set into usage counts and storage byte totals with computed totals", async () => {
query.mockResolvedValue({
rows: [
{ date: "2026-06-01", core: 3, audit: 5, core_disk: "100", audit_disk: "50" },
{ date: "2026-06-02", core: 0, audit: 0, core_disk: "100", audit_disk: "50" },
],
});
const out = await getSeries(7, { from: "2026-06-01", to: "2026-06-02" });
expect(query).toHaveBeenCalledTimes(1); // single round-trip
expect(query.mock.calls[0][1]).toEqual([7, "2026-06-01", "2026-06-02"]);
expect(out.usage).toEqual([
{ date: "2026-06-01", core: 3, audit: 5, total: 8 },
{ date: "2026-06-02", core: 0, audit: 0, total: 0 },
]);
expect(out.storage).toEqual([
{ date: "2026-06-01", coreDisk: 100, auditDisk: 50, totalDisk: 150 },
{ date: "2026-06-02", coreDisk: 100, auditDisk: 50, totalDisk: 150 },
]);
expect(release).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,55 @@
import { getConfig } from "../../common/config.js";
import { listCore, listAudit, getEvent, getAgreement, getEventDetails } from "./transactions.js";
import { getApiUsage } from "./usage.js";
import { getStorage } from "./storage.js";
import { getSeries } from "./series.js";
import { countApiKeys } from "./account.js";
import { getCached } from "./cache.js";
import { verifyMany } from "./verify.js";
// Expensive full per-tenant byte scan that changes slowly.
const STORAGE_TTL_MS = 4 * 60 * 60 * 1000;
export async function summary(userId, { from, to }) {
const [apiUsage, apiKeys, storage] = await Promise.all([
getApiUsage(userId, { from, to }),
countApiKeys(userId),
getCached(userId, "storage", STORAGE_TTL_MS, () => getStorage(userId)),
]);
return {
apiUsage,
apiKeys,
// Externally-facing modules only, not internal ones like `dashboard`.
endpoints: Object.values(getConfig().appModules).filter((m) => !m?.internal).length,
storage,
};
}
export async function transactions(userId, opts) {
const { rows, total } = opts.type === "audit"
? await listAudit(userId, opts)
: await listCore(userId, opts);
return { rows, total, limit: opts.limit, offset: opts.offset };
}
export async function series(userId, window) {
return getSeries(userId, window);
}
export async function event(userId, eventUuid) {
return getEvent(userId, eventUuid);
}
export async function agreement(userId, agreementUuid) {
return getAgreement(userId, agreementUuid);
}
export async function verify(userId, targets) {
return { results: await verifyMany(userId, targets) };
}
export async function details(userId, eventUuid) {
return getEventDetails(userId, eventUuid);
}
export const service = { summary, transactions, series, event, verify, agreement, details };

View File

@@ -0,0 +1,39 @@
import { getPool } from "../../db/index.js";
// Audit rows carry no user_id, so they are attributed via event OR agreement
// uuid — deliberately wider than the event-only listAudit view.
export async function getStorage(userId) {
const client = await getPool();
try {
const row = (
await client.query(
`WITH ua AS (
SELECT a.audit_id, a.digest
FROM audit a
WHERE a.event_id IN (SELECT event_id_uuid FROM event WHERE user_id = $1)
OR a.agreement_id IN (SELECT agreement_id_uuid FROM agreement WHERE user_id = $1)
)
SELECT
(SELECT COALESCE(SUM(pg_column_size(data)), 0)::bigint FROM event_data WHERE user_id = $1) AS core_on_disk,
(SELECT COALESCE(SUM(octet_length(data)), 0)::bigint FROM event_data WHERE user_id = $1) AS core_logical,
COALESCE(SUM(pg_column_size(ua.digest)), 0)::bigint
+ COALESCE((SELECT SUM(pg_column_size(s.jws)) FROM audit_signature s WHERE s.audit_id IN (SELECT audit_id FROM ua)), 0)::bigint AS audit_on_disk,
COALESCE(SUM(octet_length(ua.digest)), 0)::bigint
+ COALESCE((SELECT SUM(octet_length(s.jws)) FROM audit_signature s WHERE s.audit_id IN (SELECT audit_id FROM ua)), 0)::bigint AS audit_logical
FROM ua`,
[userId],
)
).rows[0];
const coreOnDisk = Number(row.core_on_disk);
const auditOnDisk = Number(row.audit_on_disk);
const coreLogical = Number(row.core_logical);
const auditLogical = Number(row.audit_logical);
return {
onDisk: { core: coreOnDisk, audit: auditOnDisk, total: coreOnDisk + auditOnDisk },
logical: { core: coreLogical, audit: auditLogical, total: coreLogical + auditLogical },
};
} finally {
await client.release();
}
}

View File

@@ -0,0 +1,30 @@
import { jest } from "@jest/globals";
const query = jest.fn();
const release = jest.fn(async () => {});
jest.unstable_mockModule("../../db/index.js", () => ({ getPool: async () => ({ query, release }) }));
const { getStorage } = await import("./storage.js");
beforeEach(() => {
query.mockReset();
release.mockClear();
});
describe("getStorage (core + audit in one query)", () => {
it("runs a single query and shapes on-disk/logical core/audit/total byte totals", async () => {
query.mockResolvedValue({
rows: [{ core_on_disk: "100", core_logical: "120", audit_on_disk: "50", audit_logical: "60" }],
});
const out = await getStorage(7);
expect(query).toHaveBeenCalledTimes(1); // combined round-trip
expect(query.mock.calls[0][1]).toEqual([7]);
expect(out).toEqual({
onDisk: { core: 100, audit: 50, total: 150 },
logical: { core: 120, audit: 60, total: 180 },
});
expect(release).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,248 @@
import { marked } from "marked";
import { getPool } from "../../db/index.js";
import { sortClause, eventSearchClause } from "./query.js";
// Every query is scoped to user_id; the only interpolated SQL is the
// whitelisted sort clause. Agreements are deliberately NOT re-scoped by
// user_id — an event may reference a public (user_id IS NULL) agreement.
export async function listCore(userId, { from, to, q = "", sort, dir, limit = 50, offset = 0 } = {}) {
const client = await getPool();
try {
const params = [userId];
let where = `WHERE e.user_id = $1`;
if (from && to) {
params.push(from, to);
// Half-open: includes the whole `to` day. `<= to::date` would drop
// everything after midnight of that day.
where += ` AND e.created_as_ts >= $${params.length - 1}::date AND e.created_as_ts < ($${params.length}::date + 1)`;
}
if (q) {
params.push(q);
where += ` AND ${eventSearchClause(params.length)}`;
}
const total = (
await client.query(
`SELECT COUNT(*)::int AS total
FROM event e
INNER JOIN agreement agr ON e.agreement_id = agr.id
${where}`,
params,
)
).rows[0].total;
const rows = (
await client.query(
`SELECT
e.event_id_uuid AS "eventUuid",
COALESCE(es.short_name, e.sender_id) AS sender,
COALESCE(er.short_name, e.recipient_id) AS receiver,
e.created_as_ts AS date,
agr.agreement_id_uuid AS "agreementUuid",
ac.title AS "agreementTitle",
octet_length(ed.data::text) AS "size"
FROM event e
INNER JOIN agreement agr ON e.agreement_id = agr.id
LEFT JOIN agreement_content ac ON ac.agreement_uuid = agr.agreement_id_uuid
LEFT JOIN event_data ed ON ed.event_id = e.id
LEFT JOIN entity es ON es.did_id = e.sender_id AND es.user_id = $1
LEFT JOIN entity er ON er.did_id = e.recipient_id AND er.user_id = $1
${where}
${sortClause(sort, dir)}
LIMIT $${params.length + 1} OFFSET $${params.length + 2}`,
[...params, limit, offset],
)
).rows;
return { rows, total };
} finally {
await client.release();
}
}
// One row per AUDIT RECORD, not per event: `eventUuid` repeats across rows and
// `auditId` is what identifies a row uniquely.
export async function listAudit(userId, { from, to, q = "", sort, dir, limit = 50, offset = 0 } = {}) {
const client = await getPool();
try {
const params = [userId];
let where = `WHERE a.event_id IS NOT NULL`;
if (from && to) {
params.push(from, to);
where += ` AND a.created_ts >= $${params.length - 1}::date AND a.created_ts < ($${params.length}::date + 1)`;
}
if (q) {
params.push(q);
where += ` AND ${eventSearchClause(params.length)}`;
}
const baseFrom = `
FROM audit a
INNER JOIN event e ON e.event_id_uuid = a.event_id AND e.user_id = $1
INNER JOIN agreement agr ON agr.id = e.agreement_id`;
const total = (
await client.query(`SELECT COUNT(*)::int AS total ${baseFrom} ${where}`, params)
).rows[0].total;
const rows = (
await client.query(
`SELECT
a.audit_id AS "auditId",
a.digest AS "auditDigest",
e.event_id_uuid AS "eventUuid",
COALESCE(es.short_name, e.sender_id) AS sender,
COALESCE(er.short_name, e.recipient_id) AS receiver,
a.created_ts AS date,
agr.agreement_id_uuid AS "agreementUuid",
ac.title AS "agreementTitle",
(
octet_length(a.digest)
+ COALESCE((SELECT SUM(octet_length(s.jws)) FROM audit_signature s WHERE s.audit_id = a.audit_id), 0)
) AS "size"
${baseFrom}
LEFT JOIN agreement_content ac ON ac.agreement_uuid = agr.agreement_id_uuid
LEFT JOIN entity es ON es.did_id = e.sender_id AND es.user_id = $1
LEFT JOIN entity er ON er.did_id = e.recipient_id AND er.user_id = $1
${where}
${sortClause(sort, dir)}
LIMIT $${params.length + 1} OFFSET $${params.length + 2}`,
[...params, limit, offset],
)
).rows;
return { rows, total };
} finally {
await client.release();
}
}
export async function getEvent(userId, eventUuid) {
const client = await getPool();
try {
const record =
(
await client.query(
`SELECT JSON_BUILD_OBJECT(
'version', e.version,
'eventId', e.event_id_uuid,
'type', et.value,
'senderId', e.sender_id,
'recipientId', e.recipient_id,
'created', e.created,
'agreementId', agr.agreement_id_uuid,
'data', ed.data
) AS record
FROM event e
JOIN event_type et ON et.id = e.event_type_id
JOIN agreement agr ON agr.id = e.agreement_id
LEFT JOIN event_data ed ON ed.event_id = e.id
WHERE e.user_id = $1 AND e.event_id_uuid = $2`,
[userId, eventUuid],
)
).rows[0]?.record ?? null;
if (record?.data) {
try {
record.data = JSON.parse(record.data);
} catch {
// leave the raw string if it isn't JSON
}
}
return record;
} finally {
await client.release();
}
}
// Cached agreement markdown rendered to HTML for the "click the agreement" modal.
// Scoped to the viewer: their own agreement or a public (user_id IS NULL) one.
export async function getAgreement(userId, agreementUuid) {
const client = await getPool();
try {
const row = (
await client.query(
`SELECT title, markdown FROM agreement_content
WHERE agreement_uuid = $1 AND (user_id = $2 OR user_id IS NULL)
LIMIT 1`,
[agreementUuid, userId],
)
).rows[0];
if (!row) return null;
return { title: row.title, html: marked(row.markdown || "") };
} finally {
await client.release();
}
}
export async function getEventDetails(userId, eventUuid) {
const client = await getPool();
try {
const event = await getEvent(userId, eventUuid);
if (!event) return null;
const result = await client.query(`
SELECT s.signer_id, r.value AS role
FROM signature s
JOIN agreement agr ON agr.id = s.agreement_id
LEFT JOIN role r ON r.id = s.role_id
WHERE agr.agreement_id_uuid = $1
`, [event.agreementId])
const roleByDid = {};
for (const row of result.rows) {
roleByDid[row.signer_id] = row.role;
}
const roles = {
sender: roleByDid[event.senderId] || null,
receiver: roleByDid[event.recipientId] || null
}
// Resolve the sender/receiver DIDs to their entity short names (user@fedid…).
const nameResult = await client.query(`
SELECT did_id, short_name FROM entity
WHERE user_id = $1 AND did_id = ANY($2::text[])
`, [userId, [event.senderId, event.recipientId]])
const nameByDid = {}
for (const row of nameResult.rows) nameByDid[row.did_id] = row.short_name
const names = {
sender: nameByDid[event.senderId] || null,
receiver: nameByDid[event.recipientId] || null
}
const permittedResult = await client.query(`
SELECT p.value FROM purpose p
INNER JOIN agreement_purpose ap
ON p.id = ap.purpose_id
INNER JOIN agreement a ON ap.agreement_id = a.id
WHERE a.agreement_id_uuid = $1
`, [event.agreementId])
const permitted = permittedResult.rows.map(row => row.value);
const prohibitedResult = await client.query(`
SELECT p.value FROM prohibition p
INNER JOIN agreement_prohibition aph
ON p.id = aph.prohibition_id
INNER JOIN agreement a ON aph.agreement_id = a.id
WHERE a.agreement_id_uuid = $1
`, [event.agreementId])
const prohibited = prohibitedResult.rows.map(row => row.value)
const title = (await client.query(`
SELECT title FROM agreement_content
WHERE agreement_uuid = $1
`, [event.agreementId])).rows[0]?.title ?? null;
return {
event,
roles,
names,
agreement: {permitted, prohibited, title}
}
} finally {
client.release();
}
}

View File

@@ -0,0 +1,63 @@
import { jest } from "@jest/globals";
// Manual DB mock — assert the SQL/params the query builder produces without a DB.
const query = jest.fn();
const release = jest.fn(async () => {});
jest.unstable_mockModule("../../db/index.js", () => ({ getPool: async () => ({ query, release }) }));
const { listCore, listAudit } = await import("./transactions.js");
// listCore/listAudit each run a COUNT then a rows query.
function prime(total = 0, rows = []) {
query.mockResolvedValueOnce({ rows: [{ total }] }).mockResolvedValueOnce({ rows });
}
beforeEach(() => {
query.mockReset();
release.mockClear();
});
describe("listCore search guard", () => {
it("omits the ILIKE search predicate (and the q param) when there is no query term", async () => {
prime(0, []);
await listCore(7, { from: "2026-01-01", to: "2026-01-31" });
const [countSql, countParams] = query.mock.calls[0];
expect(countSql).not.toMatch(/ILIKE/);
expect(countParams).toEqual([7, "2026-01-01", "2026-01-31"]); // no q appended
});
it("adds the ILIKE search predicate + q param only when a query term is present", async () => {
prime(1, [{ eventUuid: "e1" }]);
await listCore(7, { from: "2026-01-01", to: "2026-01-31", q: "acme" });
const [countSql, countParams] = query.mock.calls[0];
expect(countSql).toMatch(/ILIKE/);
expect(countParams).toEqual([7, "2026-01-01", "2026-01-31", "acme"]);
});
it("resolves sender/receiver to entity short names via COALESCE + LEFT JOIN entity", async () => {
prime(1, [{ eventUuid: "e1", sender: "acme", receiver: "did:x" }]);
const out = await listCore(7, {});
const rowsSql = query.mock.calls[1][0];
expect(rowsSql).toMatch(/COALESCE\(es\.short_name, e\.sender_id\)/);
expect(rowsSql).toMatch(/COALESCE\(er\.short_name, e\.recipient_id\)/);
expect(rowsSql).toMatch(/LEFT JOIN entity/);
expect(out).toEqual({ rows: [{ eventUuid: "e1", sender: "acme", receiver: "did:x" }], total: 1 });
});
});
describe("listAudit", () => {
it("omits the ILIKE search predicate by default", async () => {
prime(0, []);
await listAudit(7, { from: "2026-01-01", to: "2026-01-31" });
expect(query.mock.calls[0][0]).not.toMatch(/ILIKE/);
});
it("keeps the display-only entity joins OUT of the COUNT query but IN the rows query", async () => {
prime(2, [{ eventUuid: "e1" }]);
await listAudit(7, { q: "x" });
const countSql = query.mock.calls[0][0];
const rowsSql = query.mock.calls[1][0];
expect(countSql).not.toMatch(/LEFT JOIN entity/);
expect(rowsSql).toMatch(/LEFT JOIN entity/);
});
});

View File

@@ -0,0 +1,25 @@
import { getPool } from "../../db/index.js";
// The 'archive' module is surfaced to the UI as "audit".
export async function getApiUsage(userId, { from, to }) {
const client = await getPool();
try {
const r = (
await client.query(
`SELECT
COALESCE(SUM(CASE WHEN uu.module = 'core' THEN 1 END), 0)::int AS core,
COALESCE(SUM(CASE WHEN uu.module = 'archive' THEN 1 END), 0)::int AS audit
FROM usage u
JOIN usage_url uu ON uu.id = u.usage_url_id
WHERE u.user_id = $1
AND u.created_ts >= $2::date
AND u.created_ts < ($3::date + 1)`,
[userId, from, to],
)
).rows[0];
return { core: r.core, audit: r.audit, total: r.core + r.audit };
} finally {
await client.release();
}
}

View File

@@ -0,0 +1,63 @@
import { windowParams, clampLimit, clampOffset, SORTABLE_KEYS } from "./query.js";
// Enums are rejected when present-but-invalid (400); numeric/date params are
// leniently clamped so an odd page size still returns sensible data.
const TYPES = ["core", "audit"];
const DIRS = ["asc", "desc"];
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
export const isUuid = (s) => typeof s === "string" && UUID_RE.test(s);
export function parseWindow(query = {}) {
return windowParams(query);
}
const MAX_VERIFY_TARGETS = 200;
const splitList = (v) => (typeof v === "string" ? v.split(",") : []).map((s) => s.trim()).filter(Boolean);
// `ids` are event UUIDs (core tab); `auditIds` are audit records (audit tab,
// where rows share an event UUID). The cap applies to both lists combined.
export function parseVerifyTargets(query = {}) {
const eventUuids = [...new Set(splitList(query.ids).filter(isUuid))].slice(0, MAX_VERIFY_TARGETS);
const auditIds = [...new Set(splitList(query.auditIds).map(Number))]
.filter((n) => Number.isInteger(n) && n > 0)
.slice(0, Math.max(0, MAX_VERIFY_TARGETS - eventUuids.length));
return { eventUuids, auditIds };
}
export function parseTransactionsQuery(query = {}) {
const type = query.type ?? "core";
if (!TYPES.includes(type)) {
return { ok: false, error: `type must be one of ${TYPES.join(", ")}` };
}
let sort = query.sort;
if (sort != null && !SORTABLE_KEYS.includes(sort)) {
return { ok: false, error: `sort must be one of ${SORTABLE_KEYS.join(", ")}` };
}
if (sort == null) sort = undefined;
const dir = query.dir ?? "desc";
if (!DIRS.includes(dir)) {
return { ok: false, error: `dir must be one of ${DIRS.join(", ")}` };
}
const { from, to } = windowParams(query);
const q = typeof query.q === "string" ? query.q.slice(0, 200) : "";
return {
ok: true,
value: {
type,
from,
to,
q,
sort,
dir,
limit: clampLimit(query.limit),
offset: clampOffset(query.offset),
},
};
}

View File

@@ -0,0 +1,101 @@
import { parseTransactionsQuery, parseWindow, parseVerifyTargets, isUuid } from "./validate.js";
describe("parseTransactionsQuery", () => {
it("defaults type=core, dir=desc, sort undefined, and a 30-day window", () => {
const { ok, value } = parseTransactionsQuery({});
expect(ok).toBe(true);
expect(value.type).toBe("core");
expect(value.dir).toBe("desc");
expect(value.sort).toBeUndefined();
expect(value.limit).toBe(50);
expect(value.offset).toBe(0);
expect(value.from).toMatch(/^\d{4}-\d{2}-\d{2}$/);
});
it("accepts valid discrete params", () => {
const { ok, value } = parseTransactionsQuery({ type: "audit", sort: "size", dir: "asc" });
expect(ok).toBe(true);
expect(value).toMatchObject({ type: "audit", sort: "size", dir: "asc" });
});
it("rejects an invalid type", () => {
expect(parseTransactionsQuery({ type: "bogus" })).toMatchObject({ ok: false });
});
it("rejects an invalid sort key", () => {
expect(parseTransactionsQuery({ sort: "sender; DROP" })).toMatchObject({ ok: false });
});
it("rejects an invalid dir", () => {
expect(parseTransactionsQuery({ dir: "sideways" })).toMatchObject({ ok: false });
});
it("clamps limit/offset and caps search length", () => {
const { value } = parseTransactionsQuery({ limit: "99999", offset: "-3", q: "x".repeat(500) });
expect(value.limit).toBe(200);
expect(value.offset).toBe(0);
expect(value.q).toHaveLength(200);
});
});
describe("parseWindow", () => {
it("normalizes to a { from, to } window", () => {
expect(parseWindow({ from: "2026-01-01", to: "2026-01-31" })).toEqual({
from: "2026-01-01",
to: "2026-01-31",
});
});
});
describe("parseVerifyTargets", () => {
const U1 = "3fa85f64-5717-4562-b3fc-2c963f66afa6";
const U2 = "11111111-2222-3333-4444-555555555555";
it("keeps valid UUIDs, drops junk, and de-duplicates", () => {
expect(parseVerifyTargets({ ids: `${U1}, junk ,${U2},${U1}` })).toEqual({
eventUuids: [U1, U2],
auditIds: [],
});
});
it("parses audit ids as positive integers, dropping anything else", () => {
expect(parseVerifyTargets({ auditIds: "9, 10 ,9,0,-3,abc,1.5" })).toEqual({
eventUuids: [],
auditIds: [9, 10],
});
});
it("accepts both lists at once (core rows by event, audit rows by audit id)", () => {
expect(parseVerifyTargets({ ids: U1, auditIds: "42" })).toEqual({
eventUuids: [U1],
auditIds: [42],
});
});
it("returns empty lists when absent or empty", () => {
expect(parseVerifyTargets({})).toEqual({ eventUuids: [], auditIds: [] });
expect(parseVerifyTargets({ ids: "", auditIds: "" })).toEqual({ eventUuids: [], auditIds: [] });
});
it("caps the two lists at 200 targets COMBINED", () => {
const auditIds = Array.from({ length: 250 }, (_, i) => i + 1).join(",");
const one = parseVerifyTargets({ auditIds });
expect(one.auditIds).toHaveLength(200);
// 150 events leaves room for only 50 audit ids.
const events = Array.from({ length: 150 }, (_, i) =>
`3fa85f64-5717-4562-b3fc-${String(i).padStart(12, "0")}`).join(",");
const both = parseVerifyTargets({ ids: events, auditIds });
expect(both.eventUuids).toHaveLength(150);
expect(both.auditIds).toHaveLength(50);
});
});
describe("isUuid", () => {
it("accepts canonical UUIDs and rejects junk", () => {
expect(isUuid("3fa85f64-5717-4562-b3fc-2c963f66afa6")).toBe(true);
expect(isUuid("not-a-uuid")).toBe(false);
expect(isUuid("")).toBe(false);
expect(isUuid(null)).toBe(false);
});
});

View File

@@ -0,0 +1,146 @@
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;
}

View File

@@ -0,0 +1,225 @@
import { jest } from "@jest/globals";
import { summarizeVerification, verifyMany } from "./verify.js";
// Build the record shape fetchAuditRecords produces.
const record = (auditId, sigCount = 1) => ({
auditId,
audit: { version: 1, hashType: "sha256", digest: `d${auditId}`, created: 100, eventId: "e1" },
signatures: Array.from({ length: sigCount }, (_, i) => ({ id: `alice@a`, jws: `x${i}` })),
});
// A verifier verdict as the canonical verifier returns it: the record we passed
// in, echoed back on `.audit`, plus its per-check results.
const verdict = (rec, results) => ({ audit: rec, results });
describe("summarizeVerification", () => {
it("reports no audit record when nothing was found", () => {
expect(summarizeVerification({ eventUuid: "e1", auditId: null }, [], null)).toEqual({
eventUuid: "e1",
auditId: null,
status: "no-audit",
verified: false,
signatureCount: 0,
auditCount: 0,
checks: null,
reason: "no audit record",
});
});
it("marks verified when the record's verdict is in the valid bucket", () => {
const r = record(9, 2);
const result = { data: { valid: [verdict(r, { idMatch: true, auditSig: true })], invalid: [] } };
expect(summarizeVerification({ eventUuid: null, auditId: 9 }, [r], result)).toEqual({
eventUuid: null,
auditId: 9,
status: "verified",
verified: true,
signatureCount: 2,
auditCount: 1,
checks: { idMatch: true, auditSig: true },
});
});
it("marks invalid and surfaces the failed checks", () => {
const r = record(9, 2);
const result = { data: { valid: [], invalid: [verdict(r, { auditSig: false })] } };
expect(summarizeVerification({ eventUuid: null, auditId: 9 }, [r], result)).toMatchObject({
status: "invalid",
verified: false,
checks: { auditSig: false },
});
});
// The regression this whole change is about: one event, two audit records.
describe("an event with several audit records", () => {
const produce = record(9);
const process = record(10);
const target = { eventUuid: "e1", auditId: null };
it("matches each verdict to its own record by audit id, not by position", () => {
// Deliberately out of order: `process` is valid, `produce` is not.
const result = {
data: {
valid: [verdict(process, { auditSig: true })],
invalid: [verdict(produce, { auditSig: false })],
},
};
const out = summarizeVerification(target, [produce, process], result);
expect(out).toMatchObject({ status: "invalid", verified: false, auditCount: 2 });
// The failing record's checks are what surface, not the first bucket's.
expect(out.checks).toEqual({ auditSig: false });
});
it("only reports verified when EVERY audit record passed", () => {
const allGood = {
data: { valid: [verdict(produce, { auditSig: true }), verdict(process, { auditSig: true })], invalid: [] },
};
expect(summarizeVerification(target, [produce, process], allGood)).toMatchObject({
status: "verified",
verified: true,
auditCount: 2,
signatureCount: 2,
});
});
});
});
// A fake pooled client that routes by SQL so we can exercise verifyMany's
// orchestration without a DB or the crypto engine. `entity` is matched first so
// the audit query's audit_signature JOIN can't shadow it.
function routingClient(routes) {
const query = jest.fn(async (sql, params) => {
if (/FROM entity/.test(sql)) return { rows: routes.entity ?? [] };
if (/FROM audit/.test(sql)) return { rows: routes.audit?.(sql, params) ?? [] };
return { rows: [] };
});
return { query, release: jest.fn(async () => {}) };
}
// One audit row as it comes back from Postgres (snake_case + aggregated JSON).
const auditRow = (auditId, eventId = "e1") => ({
audit_id: auditId,
version: 1,
event_id: eventId,
hash_type: "sha256",
digest: `d${auditId}`,
created: "100",
sender_id: "did:alice",
recipient_id: "did:bob",
signatures: [{ version: 1, id: "alice@a", signedon: "101", type: "audit", jws: "x" }],
});
describe("verifyMany", () => {
it("returns [] when there are no targets", async () => {
expect(await verifyMany(1, {})).toEqual([]);
expect(await verifyMany(1, { eventUuids: [], auditIds: [] })).toEqual([]);
});
it("verifies an event by assembling its audit records and calling the verifier", async () => {
const client = routingClient({
entity: [{ did_id: "did:alice", did_doc: { id: "did:alice" } }],
audit: () => [auditRow(9)],
});
const verify = jest.fn(async (input) => ({
data: { valid: [{ audit: input.audits[0], results: { ok: true } }], invalid: [] },
}));
const out = await verifyMany(1, { eventUuids: ["e1"] }, { acquire: async () => client, verify });
expect(out).toEqual([{
eventUuid: "e1",
auditId: null,
status: "verified",
verified: true,
signatureCount: 1,
auditCount: 1,
checks: { ok: true },
}]);
expect(verify).toHaveBeenCalledWith(
expect.objectContaining({
didDocs: [{ id: "did:alice" }],
audits: [expect.objectContaining({ auditId: 9, audit: expect.objectContaining({ eventId: "e1" }) })],
}),
1,
);
});
it("resolves only the DIDs the batch references, once, and never all entities", async () => {
const entityCalls = [];
const client = routingClient({
entity: [{ did_id: "did:alice", did_doc: { id: "did:alice" } }],
audit: (_sql, params) => [auditRow(params[1])],
});
const inner = client.query;
client.query = jest.fn(async (sql, params) => {
if (/FROM entity/.test(sql)) entityCalls.push(params);
return inner(sql, params);
});
const verify = jest.fn(async (input) => ({
data: { valid: [{ audit: input.audits[0], results: { ok: true } }], invalid: [] },
}));
await verifyMany(1, { auditIds: [9, 10] }, { acquire: async () => client, verify });
// One lookup for the batch, scoped to the two DIDs on those events.
expect(entityCalls).toHaveLength(1);
expect(entityCalls[0]).toEqual([1, ["did:alice", "did:bob"]]);
// Second target reused the cache rather than re-querying.
expect(verify.mock.calls[1][0].didDocs).toEqual([{ id: "did:alice" }]);
});
it("looks an audit-tab target up by audit_id, scoped to the user", async () => {
let seen;
const client = routingClient({
entity: [{ did_id: "did:alice", did_doc: { id: "did:alice" } }],
audit: (sql, params) => { seen = { sql, params }; return [auditRow(42)]; },
});
const verify = jest.fn(async (input) => ({
data: { valid: [{ audit: input.audits[0], results: { ok: true } }], invalid: [] },
}));
const out = await verifyMany(1, { auditIds: [42] }, { acquire: async () => client, verify });
expect(out[0]).toMatchObject({ auditId: 42, eventUuid: null, status: "verified" });
expect(seen.sql).toMatch(/a\.audit_id = \$2/);
expect(seen.sql).not.toMatch(/a\.event_id = \$2/);
// user_id is always $1 — an audit id alone never reaches another tenant's row.
expect(seen.params.slice(0, 2)).toEqual([1, 42]);
});
it("gives each audit record of one event its own independent result", async () => {
// Both rows belong to event e1; only audit 9 verifies.
const client = routingClient({
entity: [{ did_id: "did:alice", did_doc: { id: "did:alice" } }],
audit: (_sql, params) => [auditRow(params[1])],
});
const verify = jest.fn(async (input) => {
const rec = input.audits[0];
return rec.auditId === 9
? { data: { valid: [{ audit: rec, results: { ok: true } }], invalid: [] } }
: { data: { valid: [], invalid: [{ audit: rec, results: { ok: false } }] } };
});
const out = await verifyMany(1, { auditIds: [9, 10] }, { acquire: async () => client, verify });
expect(out).toHaveLength(2);
expect(out[0]).toMatchObject({ auditId: 9, verified: true });
expect(out[1]).toMatchObject({ auditId: 10, verified: false, status: "invalid" });
});
it("reports no audit record when the target has none", async () => {
const client = routingClient({ entity: [{ short_name: "alice@a" }], audit: () => [] });
const verify = jest.fn();
const out = await verifyMany(1, { eventUuids: ["missing"] }, { acquire: async () => client, verify });
expect(out[0]).toMatchObject({ status: "no-audit", verified: false, signatureCount: 0, reason: "no audit record" });
expect(verify).not.toHaveBeenCalled();
});
it("caps the batch at 200 targets regardless of input length", async () => {
const client = routingClient({ entity: [{ short_name: "a" }], audit: () => [] });
const eventUuids = Array.from({ length: 150 }, (_, i) => `id-${i}`);
const auditIds = Array.from({ length: 150 }, (_, i) => i + 1);
const out = await verifyMany(1, { eventUuids, auditIds }, { acquire: async () => client, verify: jest.fn() });
expect(out).toHaveLength(200);
});
});