addition of new dashboard
This commit is contained in:
41
backend/modules/app/archive.js
Normal file
41
backend/modules/app/archive.js
Normal file
@@ -0,0 +1,41 @@
|
||||
import { getConfig } from "../../common/config.js";
|
||||
|
||||
const key = 'archive';
|
||||
|
||||
const logo = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100.47 100.47">
|
||||
<defs>
|
||||
<style>.cls-1{fill:#fff;}.cls-2{fill:#31a9ba;}</style>
|
||||
</defs>
|
||||
<title>JLINC Icon</title>
|
||||
<g id="Layer_2" data-name="Layer 2">
|
||||
<g id="Layer_1-2" data-name="Layer 1">
|
||||
<path class="cls-2"
|
||||
d="M8.52,41.22l9,9L36.46,69.16l7.76-7.76L25.3,42.48l-9.42-9.42q-4.5-4.52-5.14-9.26c-.48-3.11,1-6.36,4.35-9.73Q19.22,9.95,23.64,10t9.26,4.83l4,4,7.2-7.21L38.52,6a20.93,20.93,0,0,0-7.91-4.9A17.1,17.1,0,0,0,20,.61Q14,2,8.05,8a28.56,28.56,0,0,0-6.33,9.5,18.2,18.2,0,0,0-.79,11.4Q2.34,35,8.52,41.22Z" />
|
||||
<path class="cls-2"
|
||||
d="M61.4,56.25,42.48,75.17l-9.42,9.42q-4.52,4.51-9.26,5.14t-9.73-4.35Q9.95,81.27,10,76.83t4.83-9.26l4-3.95-7.21-7.21L6,62a21,21,0,0,0-4.9,7.92A17.09,17.09,0,0,0,.61,80.48q1.43,6,7.36,12a28.87,28.87,0,0,0,9.5,6.33,18.2,18.2,0,0,0,11.4.79Q35,98.13,41.22,92l9-9L69.16,64Z" />
|
||||
<path class="cls-2"
|
||||
d="M92,59.26l-9-9L64,31.31l-7.76,7.76L75.17,58l9.42,9.42q4.51,4.52,5.14,9.26t-4.35,9.74c-2.74,2.74-5.59,4.12-8.55,4.11s-6-1.55-9.26-4.83l-3.95-4-7.21,7.2L62,94.48a21.11,21.11,0,0,0,7.92,4.91,17.06,17.06,0,0,0,10.6.47q6-1.42,12-7.36A28.87,28.87,0,0,0,98.76,83a18.16,18.16,0,0,0,.79-11.39Q98.13,65.43,92,59.26Z" />
|
||||
<path class="cls-2"
|
||||
d="M39.07,44.22,58,25.3l9.42-9.42q4.52-4.5,9.26-5.14c3.12-.48,6.36,1,9.74,4.35,2.74,2.75,4.11,5.59,4.11,8.55s-1.55,6-4.83,9.26l-4,4,7.2,7.2,5.54-5.54a21.07,21.07,0,0,0,4.91-7.91A17.17,17.17,0,0,0,99.86,20Q98.44,14,92.5,8.05A28.56,28.56,0,0,0,83,1.72,18.19,18.19,0,0,0,71.6.93Q65.44,2.34,59.26,8.52l-9,9L31.31,36.46Z" />
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
`
|
||||
|
||||
export function getModuleConfig() {
|
||||
const config = getConfig();
|
||||
return {
|
||||
title: 'JLINC Archive/Audit',
|
||||
logo,
|
||||
type: key,
|
||||
endpoint: config.publicArchiveUrl,
|
||||
background: {
|
||||
color1: 'rgb(79, 55, 139)',
|
||||
color2: 'rgb(19, 2, 28)',
|
||||
},
|
||||
button: {
|
||||
color: 'rgb(255, 205, 57)',
|
||||
},
|
||||
}
|
||||
}
|
||||
41
backend/modules/app/core.js
Normal file
41
backend/modules/app/core.js
Normal file
@@ -0,0 +1,41 @@
|
||||
import { getConfig } from "../../common/config.js";
|
||||
|
||||
const key = 'core';
|
||||
|
||||
const logo = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100.47 100.47">
|
||||
<defs>
|
||||
<style>.cls-1{fill:#fff;}.cls-2{fill:#31a9ba;}</style>
|
||||
</defs>
|
||||
<title>JLINC Icon</title>
|
||||
<g id="Layer_2" data-name="Layer 2">
|
||||
<g id="Layer_1-2" data-name="Layer 1">
|
||||
<path class="cls-2"
|
||||
d="M8.52,41.22l9,9L36.46,69.16l7.76-7.76L25.3,42.48l-9.42-9.42q-4.5-4.52-5.14-9.26c-.48-3.11,1-6.36,4.35-9.73Q19.22,9.95,23.64,10t9.26,4.83l4,4,7.2-7.21L38.52,6a20.93,20.93,0,0,0-7.91-4.9A17.1,17.1,0,0,0,20,.61Q14,2,8.05,8a28.56,28.56,0,0,0-6.33,9.5,18.2,18.2,0,0,0-.79,11.4Q2.34,35,8.52,41.22Z" />
|
||||
<path class="cls-2"
|
||||
d="M61.4,56.25,42.48,75.17l-9.42,9.42q-4.52,4.51-9.26,5.14t-9.73-4.35Q9.95,81.27,10,76.83t4.83-9.26l4-3.95-7.21-7.21L6,62a21,21,0,0,0-4.9,7.92A17.09,17.09,0,0,0,.61,80.48q1.43,6,7.36,12a28.87,28.87,0,0,0,9.5,6.33,18.2,18.2,0,0,0,11.4.79Q35,98.13,41.22,92l9-9L69.16,64Z" />
|
||||
<path class="cls-2"
|
||||
d="M92,59.26l-9-9L64,31.31l-7.76,7.76L75.17,58l9.42,9.42q4.51,4.52,5.14,9.26t-4.35,9.74c-2.74,2.74-5.59,4.12-8.55,4.11s-6-1.55-9.26-4.83l-3.95-4-7.21,7.2L62,94.48a21.11,21.11,0,0,0,7.92,4.91,17.06,17.06,0,0,0,10.6.47q6-1.42,12-7.36A28.87,28.87,0,0,0,98.76,83a18.16,18.16,0,0,0,.79-11.39Q98.13,65.43,92,59.26Z" />
|
||||
<path class="cls-2"
|
||||
d="M39.07,44.22,58,25.3l9.42-9.42q4.52-4.5,9.26-5.14c3.12-.48,6.36,1,9.74,4.35,2.74,2.75,4.11,5.59,4.11,8.55s-1.55,6-4.83,9.26l-4,4,7.2,7.2,5.54-5.54a21.07,21.07,0,0,0,4.91-7.91A17.17,17.17,0,0,0,99.86,20Q98.44,14,92.5,8.05A28.56,28.56,0,0,0,83,1.72,18.19,18.19,0,0,0,71.6.93Q65.44,2.34,59.26,8.52l-9,9L31.31,36.46Z" />
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
`
|
||||
|
||||
export function getModuleConfig() {
|
||||
const config = getConfig();
|
||||
return {
|
||||
title: 'JLINC Protocol',
|
||||
logo,
|
||||
type: key,
|
||||
endpoint: config.publicCoreUrl,
|
||||
background: {
|
||||
color1: 'rgb(79, 55, 139)',
|
||||
color2: 'rgb(19, 2, 28)',
|
||||
},
|
||||
button: {
|
||||
color: 'rgb(255, 205, 57)',
|
||||
},
|
||||
}
|
||||
}
|
||||
10
backend/modules/app/dashboard.js
Normal file
10
backend/modules/app/dashboard.js
Normal file
@@ -0,0 +1,10 @@
|
||||
// internal: no `app` row, not API-key mintable, excluded from the endpoint count.
|
||||
const key = 'dashboard';
|
||||
|
||||
export function getModuleConfig() {
|
||||
return {
|
||||
title: 'JLINC Dashboard',
|
||||
type: key,
|
||||
internal: true,
|
||||
};
|
||||
}
|
||||
46
backend/modules/auth/github.js
Normal file
46
backend/modules/auth/github.js
Normal file
@@ -0,0 +1,46 @@
|
||||
import GitHubStrategy from "passport-github";
|
||||
import { checkUser } from "../../http/auth.js";
|
||||
import { getConfig } from "../../common/config.js";
|
||||
|
||||
const key = 'github';
|
||||
|
||||
export function getModuleConfig() {
|
||||
const config = getConfig();
|
||||
return {
|
||||
title: 'GitHub',
|
||||
icon: 'code',
|
||||
clientID: process.env.GITHUB_CLIENT_ID,
|
||||
clientSecret: process.env.GITHUB_CLIENT_SECRET,
|
||||
callbackURL: `${config.publicCallbackUrl}/callback/${key}`,
|
||||
}
|
||||
}
|
||||
|
||||
export async function initModule(app, passport) {
|
||||
const config = getConfig();
|
||||
|
||||
passport.use(new GitHubStrategy(
|
||||
config.authModules[key],
|
||||
function (accessToken, refreshToken, profile, cb) {
|
||||
(async () => {
|
||||
try {
|
||||
const user = await checkUser(key, 'https://github.com', profile.id, profile.username);
|
||||
return cb(null, user);
|
||||
} catch (e) {
|
||||
if (config.debug)
|
||||
console.error(e);
|
||||
return cb(null, null);
|
||||
}
|
||||
})();
|
||||
}
|
||||
));
|
||||
app.get(`/login/${key}`, passport.authenticate('github'));
|
||||
app.get(`/callback/${key}`,
|
||||
passport.authenticate('github', {
|
||||
failureRedirect: '/',
|
||||
failureMessage: true
|
||||
}),
|
||||
function (req, res) {
|
||||
res.redirect('/home');
|
||||
}
|
||||
);
|
||||
}
|
||||
46
backend/modules/auth/google.js
Normal file
46
backend/modules/auth/google.js
Normal file
@@ -0,0 +1,46 @@
|
||||
import GoogleStrategy from "passport-google-oauth20";
|
||||
import { checkUser } from "../../http/auth.js";
|
||||
import { getConfig } from "../../common/config.js";
|
||||
|
||||
const key = 'google';
|
||||
|
||||
export function getModuleConfig() {
|
||||
const config = getConfig();
|
||||
return {
|
||||
title: 'Google',
|
||||
icon: 'language',
|
||||
clientID: process.env.GOOGLE_CLIENT_ID,
|
||||
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
|
||||
callbackURL: `${config.publicCallbackUrl}/callback/${key}`,
|
||||
}
|
||||
}
|
||||
|
||||
export async function initModule(app, passport) {
|
||||
const config = getConfig();
|
||||
passport.use(new GoogleStrategy.Strategy(
|
||||
config.authModules[key],
|
||||
function (accessToken, refreshToken, profile, cb) {
|
||||
(async () => {
|
||||
try {
|
||||
const photo = profile.photos?.length > 0 ? profile.photos[0].value : null;
|
||||
const user = await checkUser(key, 'https://google.com', profile.id, profile.displayName, photo);
|
||||
return cb(null, user);
|
||||
} catch (e) {
|
||||
if (config.debug)
|
||||
console.error(e);
|
||||
return cb(null, null);
|
||||
}
|
||||
})();
|
||||
}
|
||||
));
|
||||
app.get(`/login/${key}`, passport.authenticate('google', { scope: ['profile'] }));
|
||||
app.get(`/callback/${key}`,
|
||||
passport.authenticate('google', {
|
||||
failureRedirect: '/',
|
||||
failureMessage: true
|
||||
}),
|
||||
function (req, res) {
|
||||
res.redirect('/home');
|
||||
}
|
||||
);
|
||||
}
|
||||
52
backend/modules/auth/oidc.js
Normal file
52
backend/modules/auth/oidc.js
Normal file
@@ -0,0 +1,52 @@
|
||||
import OpenIDConnectStrategy from "passport-openidconnect";
|
||||
import { checkUser } from "../../http/auth.js";
|
||||
import { getConfig } from "../../common/config.js";
|
||||
|
||||
const key = 'oidc';
|
||||
|
||||
export function getModuleConfig() {
|
||||
const config = getConfig();
|
||||
return {
|
||||
title: 'FedID',
|
||||
icon: 'login',
|
||||
issuer: process.env.OIDC_ISSUER,
|
||||
authorizationURL: process.env.OIDC_AUTHORIZATION_URL,
|
||||
tokenURL: process.env.OIDC_TOKEN_URL,
|
||||
userInfoURL: process.env.OIDC_USERINFO_URL,
|
||||
logoutURL: process.env.OIDC_LOGOUT_URL,
|
||||
clientID: process.env.OIDC_CLIENT_ID,
|
||||
clientSecret: process.env.OIDC_CLIENT_SECRET,
|
||||
callbackURL: `${config.publicCallbackUrl}/callback/${key}`,
|
||||
}
|
||||
}
|
||||
|
||||
export async function initModule(app, passport) {
|
||||
const config = getConfig();
|
||||
|
||||
passport.use(new OpenIDConnectStrategy(
|
||||
config.authModules[key],
|
||||
function verify(issuer, profile, cb) {
|
||||
(async () => {
|
||||
try {
|
||||
const user = await checkUser(key, issuer, profile.id, profile.username);
|
||||
return cb(null, user);
|
||||
} catch (e) {
|
||||
if (config.debug)
|
||||
console.error(e);
|
||||
return cb(null, null);
|
||||
}
|
||||
})();
|
||||
}
|
||||
));
|
||||
app.get(`/login/${key}`,passport.authenticate('openidconnect'));
|
||||
app.get(`/callback/${key}`,
|
||||
passport.authenticate('openidconnect', {
|
||||
failureRedirect: '/',
|
||||
failureMessage: true
|
||||
}),
|
||||
function (req, res) {
|
||||
req.session.authStrategy = 'oidc';
|
||||
res.redirect('/home');
|
||||
}
|
||||
);
|
||||
}
|
||||
18
backend/modules/auth/single.js
Normal file
18
backend/modules/auth/single.js
Normal file
@@ -0,0 +1,18 @@
|
||||
import { checkUser } from "../../http/auth.js";
|
||||
import { getConfig } from "../../common/config.js";
|
||||
|
||||
const key = 'single';
|
||||
|
||||
export function getModuleConfig() {
|
||||
const config = getConfig();
|
||||
return {
|
||||
core: process.env.SINGLE_API_KEY_CORE,
|
||||
archive: process.env.SINGLE_API_KEY_ARCHIVE,
|
||||
}
|
||||
}
|
||||
|
||||
export async function initModule(app, passport) {
|
||||
if (process.env.SINGLE_API_KEY_CORE && process.env.SINGLE_API_KEY_ARCHIVE) {
|
||||
await checkUser(key, 'https://single', 'single', 'single');
|
||||
}
|
||||
}
|
||||
36
backend/modules/core/agreement.js
Normal file
36
backend/modules/core/agreement.js
Normal file
@@ -0,0 +1,36 @@
|
||||
import pkg from '@jlinc/core';
|
||||
const { JlincAgreement } = pkg;
|
||||
|
||||
|
||||
async function create(input) {
|
||||
const data = await JlincAgreement.create(input);
|
||||
const message = data?.agreementId;
|
||||
return {
|
||||
data,
|
||||
message,
|
||||
}
|
||||
}
|
||||
|
||||
async function sign(input) {
|
||||
const data = await JlincAgreement.sign(input);
|
||||
const message = data?.agreement?.agreementId;
|
||||
return {
|
||||
data,
|
||||
message,
|
||||
}
|
||||
}
|
||||
|
||||
async function send(input) {
|
||||
const data = await JlincAgreement.send(input);
|
||||
const message = data?.agreement?.agreementId;
|
||||
return {
|
||||
data,
|
||||
message,
|
||||
}
|
||||
}
|
||||
|
||||
export const agreement = {
|
||||
create,
|
||||
sign,
|
||||
send,
|
||||
}
|
||||
96
backend/modules/core/apiKey.cache.test.js
Normal file
96
backend/modules/core/apiKey.cache.test.js
Normal file
@@ -0,0 +1,96 @@
|
||||
import { jest } from "@jest/globals";
|
||||
|
||||
// The bound is read per call from config, so set it before the first verify
|
||||
// rather than before the import. Own file so the tiny bound cannot leak into
|
||||
// apiKey.test.js.
|
||||
process.env.DASHBOARD_KEY_CACHE_MAX = "2";
|
||||
|
||||
const query = jest.fn();
|
||||
const release = jest.fn(async () => {});
|
||||
jest.unstable_mockModule("../../db/index.js", () => ({
|
||||
getPool: async () => ({ query, release }),
|
||||
}));
|
||||
|
||||
const { loadConfig } = await import("../../common/config.js");
|
||||
await loadConfig();
|
||||
|
||||
const { verifyKey, generateKey, _resetKeyCache } = await import("./apiKey.js");
|
||||
|
||||
beforeEach(() => {
|
||||
query.mockReset();
|
||||
release.mockClear();
|
||||
_resetKeyCache();
|
||||
});
|
||||
|
||||
async function mintKey(id) {
|
||||
query.mockReset();
|
||||
query
|
||||
.mockResolvedValueOnce({ rows: [{ id: 2 }] }) // SELECT app
|
||||
.mockResolvedValueOnce({ rows: [{ id, created_ts: "2026-01-01" }] }); // INSERT
|
||||
const out = await generateKey(1, "core", { label: `k${id}` });
|
||||
return { raw: out.key, keyHash: query.mock.calls[1][1][3], id };
|
||||
}
|
||||
|
||||
async function verifyWithDb({ raw, keyHash, id }) {
|
||||
query.mockReset();
|
||||
query
|
||||
.mockResolvedValueOnce({ rows: [{ id, user_id: 1, app_id: 2, key_hash: keyHash }] })
|
||||
.mockResolvedValue({ rows: [] });
|
||||
const result = await verifyKey(raw);
|
||||
return { result, selects: query.mock.calls.filter((c) => /SELECT/.test(c[0])) };
|
||||
}
|
||||
|
||||
async function servedFromCache({ raw }) {
|
||||
query.mockReset();
|
||||
query.mockResolvedValue({ rows: [] }); // if it does hit the DB, it finds nothing
|
||||
const result = await verifyKey(raw);
|
||||
return result !== null && query.mock.calls.length === 0;
|
||||
}
|
||||
|
||||
describe("validated-key cache bound", () => {
|
||||
it("evicts the least recently used key once the cache is full", async () => {
|
||||
const a = await mintKey(11);
|
||||
const b = await mintKey(12);
|
||||
const c = await mintKey(13);
|
||||
|
||||
// Fill the cache to its bound of 2.
|
||||
await verifyWithDb(a);
|
||||
await verifyWithDb(b);
|
||||
|
||||
// A hit counts as a use, so probes below check the EVICTED key first.
|
||||
expect(await servedFromCache(a)).toBe(true);
|
||||
|
||||
await verifyWithDb(c);
|
||||
expect(await servedFromCache(b)).toBe(false);
|
||||
expect(await servedFromCache(a)).toBe(true);
|
||||
expect(await servedFromCache(c)).toBe(true);
|
||||
|
||||
const { result, selects } = await verifyWithDb(b);
|
||||
expect(result).toEqual({ user_id: 1, app_id: 2 });
|
||||
expect(selects).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("never grows past the bound, however many distinct keys verify", async () => {
|
||||
const keys = [];
|
||||
for (let i = 0; i < 6; i++) keys.push(await mintKey(20 + i));
|
||||
for (const k of keys) await verifyWithDb(k);
|
||||
|
||||
const cached = [];
|
||||
for (const k of keys) if (await servedFromCache(k)) cached.push(k.id);
|
||||
expect(cached).toEqual([keys[4].id, keys[5].id]);
|
||||
});
|
||||
|
||||
it("a cache hit refreshes recency without extending the entry's TTL", async () => {
|
||||
const a = await mintKey(31);
|
||||
await verifyWithDb(a);
|
||||
|
||||
const realNow = Date.now;
|
||||
try {
|
||||
// Must expire on its original deadline despite being touched.
|
||||
Date.now = () => realNow() + 31 * 60 * 1000;
|
||||
expect(await servedFromCache(a)).toBe(false);
|
||||
} finally {
|
||||
Date.now = realNow;
|
||||
}
|
||||
});
|
||||
});
|
||||
193
backend/modules/core/apiKey.js
Normal file
193
backend/modules/core/apiKey.js
Normal file
@@ -0,0 +1,193 @@
|
||||
import crypto from "crypto";
|
||||
import { promisify } from "util";
|
||||
import { getPool } from "../../db/index.js";
|
||||
import { getConfig } from "../../common/config.js";
|
||||
|
||||
const scrypt = promisify(crypto.scrypt);
|
||||
const KEYLEN = 64;
|
||||
|
||||
// Read per call rather than captured at import, so loadConfig() governs these
|
||||
// the same way it governs the rest of config.dashboard.
|
||||
//
|
||||
// Per-process, so a revoked key can linger in another worker for one TTL.
|
||||
const cacheTtlMs = () => getConfig().dashboard.keyCache.ttlMs;
|
||||
|
||||
// The TTL alone does not bound the cache: an expired entry is only dropped when
|
||||
// that same key is looked up again, so idle keys would accumulate forever.
|
||||
const cacheMaxEntries = () => getConfig().dashboard.keyCache.max;
|
||||
|
||||
// Keyed by a digest of the raw key, never the raw key. Insertion order is
|
||||
// recency order, so the front of the Map is the least-recently-used entry.
|
||||
const cache = new Map();
|
||||
const fastDigest = (raw) => crypto.createHash("sha256").update(raw).digest("hex");
|
||||
|
||||
// `expiresAt` is deliberately untouched, so a hammered key still expires on
|
||||
// time and revocation staleness stays bounded at one TTL.
|
||||
function touch(dg, entry) {
|
||||
cache.delete(dg);
|
||||
cache.set(dg, entry);
|
||||
}
|
||||
|
||||
// Sweeps only when full, so the common insert stays O(1).
|
||||
function remember(dg, entry) {
|
||||
const max = cacheMaxEntries();
|
||||
if (cacheTtlMs() <= 0) return;
|
||||
if (cache.size >= max) {
|
||||
const now = Date.now();
|
||||
for (const [k, v] of cache) if (v.expiresAt <= now) cache.delete(k);
|
||||
}
|
||||
cache.set(dg, entry);
|
||||
while (cache.size > max) {
|
||||
const lru = cache.keys().next().value;
|
||||
if (lru === undefined) break;
|
||||
cache.delete(lru);
|
||||
}
|
||||
}
|
||||
|
||||
export function _resetKeyCache() {
|
||||
cache.clear();
|
||||
}
|
||||
|
||||
// "<saltHex>:<derivedHex>". Guards a DB dump, not a weak-password guess.
|
||||
async function scryptHash(raw) {
|
||||
const salt = crypto.randomBytes(16);
|
||||
const derived = await scrypt(raw, salt, KEYLEN);
|
||||
return `${salt.toString("hex")}:${derived.toString("hex")}`;
|
||||
}
|
||||
|
||||
async function scryptVerify(raw, stored) {
|
||||
const [saltHex, derivedHex] = String(stored).split(":");
|
||||
if (!saltHex || !derivedHex) return false;
|
||||
const expected = Buffer.from(derivedHex, "hex");
|
||||
const derived = await scrypt(raw, Buffer.from(saltHex, "hex"), KEYLEN);
|
||||
return derived.length === expected.length && crypto.timingSafeEqual(derived, expected);
|
||||
}
|
||||
|
||||
const newRawKey = () => crypto.randomBytes(32).toString("hex"); // 64 hex chars
|
||||
const prefixOf = (raw) => raw.slice(0, 8);
|
||||
|
||||
export async function verifyKey(rawKey) {
|
||||
if (!rawKey) return null;
|
||||
const dg = fastDigest(rawKey);
|
||||
|
||||
const cached = cache.get(dg);
|
||||
if (cached && cached.expiresAt > Date.now()) {
|
||||
touch(dg, cached);
|
||||
return { user_id: cached.user_id, app_id: cached.app_id };
|
||||
}
|
||||
if (cached) cache.delete(dg);
|
||||
|
||||
const client = await getPool();
|
||||
try {
|
||||
const rows = (
|
||||
await client.query(
|
||||
`SELECT id, user_id, app_id, key_hash FROM api_key
|
||||
WHERE key_prefix = $1 AND (expires_ts IS NULL OR expires_ts > NOW())`,
|
||||
[prefixOf(rawKey)],
|
||||
)
|
||||
).rows;
|
||||
for (const row of rows) {
|
||||
if (await scryptVerify(rawKey, row.key_hash)) {
|
||||
client.query(`UPDATE api_key SET last_used_ts = NOW() WHERE id = $1`, [row.id]).catch(() => {});
|
||||
remember(dg, { user_id: row.user_id, app_id: row.app_id, id: row.id, expiresAt: Date.now() + cacheTtlMs() });
|
||||
return { user_id: row.user_id, app_id: row.app_id };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} finally {
|
||||
await client.release();
|
||||
}
|
||||
}
|
||||
|
||||
// Returns the RAW key ONCE; only the salted hash + prefix are persisted.
|
||||
export async function generateKey(userId, appType, { label = null, expiresTs = null } = {}) {
|
||||
const client = await getPool();
|
||||
try {
|
||||
const app = (await client.query(`SELECT id FROM app WHERE type = $1`, [appType])).rows[0];
|
||||
if (!app) throw new Error(`unknown app type: ${appType}`);
|
||||
const raw = newRawKey();
|
||||
const keyHash = await scryptHash(raw);
|
||||
const res = await client.query(
|
||||
`INSERT INTO api_key (user_id, app_id, label, key_hash, key_prefix, expires_ts)
|
||||
VALUES ($1, $2, $3, $4, $5, $6) RETURNING id, created_ts`,
|
||||
[userId, app.id, label, keyHash, prefixOf(raw), expiresTs],
|
||||
);
|
||||
return {
|
||||
id: res.rows[0].id,
|
||||
key: raw,
|
||||
prefix: prefixOf(raw),
|
||||
appType,
|
||||
label,
|
||||
expiresTs,
|
||||
createdTs: res.rows[0].created_ts,
|
||||
};
|
||||
} finally {
|
||||
await client.release();
|
||||
}
|
||||
}
|
||||
|
||||
// Idempotent import of the legacy plaintext keys in the `auth` table into the
|
||||
// hashed api_key store, so the same keys keep authenticating via verifyKey and
|
||||
// now appear in the /home API-key screen.
|
||||
export async function migrateLegacyApiKeys() {
|
||||
const client = await getPool();
|
||||
try {
|
||||
const { rows } = await client.query(
|
||||
`SELECT user_id, app_id, api_key FROM public.auth WHERE api_key IS NOT NULL`,
|
||||
);
|
||||
let imported = 0;
|
||||
for (const { user_id, app_id, api_key } of rows) {
|
||||
const prefix = prefixOf(api_key);
|
||||
const exists = await client.query(
|
||||
`SELECT 1 FROM api_key WHERE user_id = $1 AND app_id = $2 AND key_prefix = $3 LIMIT 1`,
|
||||
[user_id, app_id, prefix],
|
||||
);
|
||||
if (exists.rowCount > 0) continue;
|
||||
const keyHash = await scryptHash(api_key);
|
||||
await client.query(
|
||||
`INSERT INTO api_key (user_id, app_id, label, key_hash, key_prefix)
|
||||
VALUES ($1, $2, $3, $4, $5)`,
|
||||
[user_id, app_id, "Imported from /dashboard", keyHash, prefix],
|
||||
);
|
||||
imported++;
|
||||
}
|
||||
if (imported > 0) console.log(`Imported ${imported} legacy API key(s) into the hashed key store`);
|
||||
return imported;
|
||||
} finally {
|
||||
await client.release();
|
||||
}
|
||||
}
|
||||
|
||||
export async function listKeys(userId) {
|
||||
const client = await getPool();
|
||||
try {
|
||||
return (
|
||||
await client.query(
|
||||
`SELECT k.id, ap.type AS "appType", k.label, k.key_prefix AS "prefix",
|
||||
k.expires_ts AS "expiresTs", k.last_used_ts AS "lastUsedTs", k.created_ts AS "createdTs"
|
||||
FROM api_key k JOIN app ap ON ap.id = k.app_id
|
||||
WHERE k.user_id = $1
|
||||
ORDER BY k.created_ts DESC`,
|
||||
[userId],
|
||||
)
|
||||
).rows;
|
||||
} finally {
|
||||
await client.release();
|
||||
}
|
||||
}
|
||||
|
||||
export async function revokeKey(userId, id) {
|
||||
const client = await getPool();
|
||||
try {
|
||||
const res = await client.query(
|
||||
`DELETE FROM api_key WHERE id = $1 AND user_id = $2 RETURNING id`,
|
||||
[id, userId],
|
||||
);
|
||||
if (res.rowCount > 0) {
|
||||
for (const [k, v] of cache) if (v.id === id) cache.delete(k);
|
||||
}
|
||||
return res.rowCount > 0;
|
||||
} finally {
|
||||
await client.release();
|
||||
}
|
||||
}
|
||||
144
backend/modules/core/apiKey.test.js
Normal file
144
backend/modules/core/apiKey.test.js
Normal file
@@ -0,0 +1,144 @@
|
||||
import { jest } from "@jest/globals";
|
||||
|
||||
// Manual DB mock: getPool() returns a fake pooled client whose query() the test
|
||||
// drives. No real database, no injection seams — the module under test calls the
|
||||
// real getPool(), which Jest replaces here.
|
||||
const query = jest.fn();
|
||||
const release = jest.fn(async () => {});
|
||||
jest.unstable_mockModule("../../db/index.js", () => ({
|
||||
getPool: async () => ({ query, release }),
|
||||
}));
|
||||
|
||||
const { verifyKey, generateKey, listKeys, revokeKey, migrateLegacyApiKeys, _resetKeyCache } = await import("./apiKey.js");
|
||||
|
||||
beforeEach(() => {
|
||||
query.mockReset();
|
||||
release.mockClear();
|
||||
_resetKeyCache();
|
||||
});
|
||||
|
||||
// Mint a key through generateKey and capture the stored (salted) hash, so the
|
||||
// verifyKey tests can round-trip a real scrypt hash without a DB.
|
||||
async function mintKey({ userId = 1, appId = 2 } = {}) {
|
||||
query
|
||||
.mockResolvedValueOnce({ rows: [{ id: appId }] }) // SELECT app
|
||||
.mockResolvedValueOnce({ rows: [{ id: 9, created_ts: "2026-01-01" }] }); // INSERT
|
||||
const out = await generateKey(userId, "core", { label: "ci" });
|
||||
const insertParams = query.mock.calls[1][1];
|
||||
return { raw: out.key, keyHash: insertParams[3], out };
|
||||
}
|
||||
|
||||
describe("generateKey", () => {
|
||||
it("stores a salted scrypt hash (not the raw key) and returns the raw key once", async () => {
|
||||
const { raw, keyHash, out } = await mintKey();
|
||||
expect(raw).toMatch(/^[0-9a-f]{64}$/);
|
||||
expect(out).toMatchObject({ id: 9, prefix: raw.slice(0, 8), appType: "core", label: "ci" });
|
||||
// Stored hash is "<saltHex>:<derivedHex>" and never contains the raw key.
|
||||
expect(keyHash).toMatch(/^[0-9a-f]{32}:[0-9a-f]{128}$/);
|
||||
expect(keyHash).not.toContain(raw);
|
||||
expect(query.mock.calls[1][1]).not.toContain(raw);
|
||||
});
|
||||
|
||||
it("throws for an unknown app type", async () => {
|
||||
query.mockResolvedValueOnce({ rows: [] }); // app lookup empty
|
||||
await expect(generateKey(1, "nope", {})).rejects.toThrow("unknown app type");
|
||||
});
|
||||
});
|
||||
|
||||
describe("verifyKey", () => {
|
||||
it("returns null for an empty key without touching the DB", async () => {
|
||||
expect(await verifyKey("")).toBeNull();
|
||||
expect(query).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("verifies a real key by prefix + scrypt, then serves the cache without a DB query", async () => {
|
||||
const { raw, keyHash } = await mintKey();
|
||||
query.mockReset();
|
||||
// First verify: prefix lookup returns the candidate, UPDATE last_used is best-effort.
|
||||
query
|
||||
.mockResolvedValueOnce({ rows: [{ id: 9, user_id: 1, app_id: 2, key_hash: keyHash }] })
|
||||
.mockResolvedValue({ rows: [] });
|
||||
|
||||
const first = await verifyKey(raw);
|
||||
expect(first).toEqual({ user_id: 1, app_id: 2 });
|
||||
expect(query.mock.calls[0][1]).toEqual([raw.slice(0, 8)]); // looked up by prefix
|
||||
|
||||
// Second verify: served from the 30-min cache — no new SELECT.
|
||||
query.mockClear();
|
||||
const second = await verifyKey(raw);
|
||||
expect(second).toEqual({ user_id: 1, app_id: 2 });
|
||||
const selects = query.mock.calls.filter((c) => /SELECT/.test(c[0]));
|
||||
expect(selects).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("returns null when a candidate's hash does not match the raw key", async () => {
|
||||
const { keyHash } = await mintKey();
|
||||
query.mockReset();
|
||||
query.mockResolvedValueOnce({ rows: [{ id: 9, user_id: 1, app_id: 2, key_hash: keyHash }] });
|
||||
expect(await verifyKey("some-other-raw-key-that-does-not-match")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when no candidate row matches the prefix", async () => {
|
||||
query.mockResolvedValueOnce({ rows: [] });
|
||||
expect(await verifyKey("deadbeefcafefeed")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("listKeys", () => {
|
||||
it("returns metadata rows for the user", async () => {
|
||||
const rows = [{ id: 1, appType: "core", prefix: "ab12cd34" }];
|
||||
query.mockResolvedValueOnce({ rows });
|
||||
expect(await listKeys(7)).toEqual(rows);
|
||||
expect(query.mock.calls[0][1]).toEqual([7]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("revokeKey", () => {
|
||||
it("deletes the user's key and evicts its cached validation", async () => {
|
||||
// Prime the cache by verifying a real key (id 9).
|
||||
const { raw, keyHash } = await mintKey();
|
||||
query.mockReset();
|
||||
query
|
||||
.mockResolvedValueOnce({ rows: [{ id: 9, user_id: 1, app_id: 2, key_hash: keyHash }] })
|
||||
.mockResolvedValue({ rows: [] });
|
||||
await verifyKey(raw);
|
||||
|
||||
// Revoke id 9 -> deletes, and the cached entry is evicted so the next verify re-hits the DB.
|
||||
query.mockReset();
|
||||
query.mockResolvedValueOnce({ rowCount: 1, rows: [{ id: 9 }] });
|
||||
expect(await revokeKey(7, 9)).toBe(true);
|
||||
expect(query.mock.calls[0][1]).toEqual([9, 7]); // scoped to (id, user_id)
|
||||
|
||||
query.mockReset();
|
||||
query.mockResolvedValueOnce({ rows: [] }); // no candidate now
|
||||
expect(await verifyKey(raw)).toBeNull(); // cache was evicted -> DB consulted again
|
||||
expect(query.mock.calls[0][0]).toMatch(/SELECT/);
|
||||
});
|
||||
|
||||
it("returns false when nothing was deleted", async () => {
|
||||
query.mockResolvedValueOnce({ rowCount: 0, rows: [] });
|
||||
expect(await revokeKey(7, 3)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("migrateLegacyApiKeys", () => {
|
||||
it("imports each legacy key once, hashed and with its prefix", async () => {
|
||||
query
|
||||
.mockResolvedValueOnce({ rows: [{ user_id: 1, app_id: 2, api_key: "abcdef1234567890" }] }) // SELECT auth
|
||||
.mockResolvedValueOnce({ rowCount: 0, rows: [] }) // existence check -> not present
|
||||
.mockResolvedValueOnce({ rows: [] }); // INSERT
|
||||
expect(await migrateLegacyApiKeys()).toBe(1);
|
||||
const [sql, params] = query.mock.calls[2];
|
||||
expect(sql).toMatch(/INSERT INTO api_key/);
|
||||
expect(params[4]).toBe("abcdef12"); // key_prefix = first 8 chars of the raw key
|
||||
expect(params[3]).toContain(":"); // salted scrypt hash "<saltHex>:<derivedHex>"
|
||||
});
|
||||
|
||||
it("skips a key that was already imported (idempotent)", async () => {
|
||||
query
|
||||
.mockResolvedValueOnce({ rows: [{ user_id: 1, app_id: 2, api_key: "abcdef1234567890" }] }) // SELECT auth
|
||||
.mockResolvedValueOnce({ rowCount: 1, rows: [{ ok: 1 }] }); // existence check -> present
|
||||
expect(await migrateLegacyApiKeys()).toBe(0);
|
||||
expect(query).toHaveBeenCalledTimes(2); // SELECT + existence check, no INSERT
|
||||
});
|
||||
});
|
||||
433
backend/modules/core/archive.js
Normal file
433
backend/modules/core/archive.js
Normal file
@@ -0,0 +1,433 @@
|
||||
import { getPool } from "../../db/index.js";
|
||||
|
||||
function isUuid(uuid) {
|
||||
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-5][0-9a-f]{3}-[089ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(uuid)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function getAuditErrors(audit) {
|
||||
if (typeof audit !== 'object') {
|
||||
return 'invalid audit object format';
|
||||
}
|
||||
|
||||
if (audit.version != 1) {
|
||||
return 'audit version invalid';
|
||||
}
|
||||
|
||||
if (audit.hashType != 'SHA256') {
|
||||
return 'audit hash type invalid';
|
||||
}
|
||||
|
||||
if (!audit.digest) {
|
||||
return 'audit digest should exist';
|
||||
}
|
||||
|
||||
if (isNaN(audit.created)) {
|
||||
return 'audit created type invalid';
|
||||
}
|
||||
|
||||
if (!audit.eventId && !audit.agreementId) {
|
||||
return 'eventId or agreementId should exist';
|
||||
}
|
||||
|
||||
if (audit.eventId && !isUuid(audit.eventId)) {
|
||||
return 'eventId invalid';
|
||||
}
|
||||
|
||||
if (audit.agreementId && !isUuid(audit.agreementId)) {
|
||||
return 'agreementId invalid';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function processAudit(data) {
|
||||
const client = await getPool();
|
||||
try {
|
||||
let type = data.audit.eventId ? 'event' : 'agreement';
|
||||
const digest = data.audit.digest;
|
||||
let id = data.audit.eventId ? data.audit.eventId : data.audit.agreementId;
|
||||
await client.query(`BEGIN`);
|
||||
let res = await client.query(`
|
||||
SELECT 1
|
||||
FROM audit a
|
||||
WHERE a.${type}_id = $1
|
||||
AND digest = $2
|
||||
LIMIT 1;
|
||||
`, [
|
||||
id,
|
||||
digest,
|
||||
]);
|
||||
if (res.rows.length > 0) {
|
||||
throw new Error('audit record with id and digest exists');
|
||||
}
|
||||
res = await client.query(`
|
||||
INSERT INTO audit (
|
||||
version,
|
||||
event_id,
|
||||
agreement_id,
|
||||
hash_type,
|
||||
digest,
|
||||
created
|
||||
) VALUES (
|
||||
$1,
|
||||
$2,
|
||||
$3,
|
||||
$4,
|
||||
$5,
|
||||
$6
|
||||
) RETURNING audit_id;
|
||||
`, [
|
||||
data.audit.version,
|
||||
data.audit.eventId,
|
||||
data.audit.agreementId,
|
||||
data.audit.hashType,
|
||||
data.audit.digest,
|
||||
data.audit.created,
|
||||
]);
|
||||
for (const signature of data.signatures) {
|
||||
await client.query(`
|
||||
INSERT INTO audit_signature (
|
||||
audit_id,
|
||||
version,
|
||||
id,
|
||||
signedOn,
|
||||
type,
|
||||
jws
|
||||
) VALUES (
|
||||
$1,
|
||||
$2,
|
||||
$3,
|
||||
$4,
|
||||
$5,
|
||||
$6
|
||||
);
|
||||
`, [
|
||||
res.rows[0].audit_id,
|
||||
signature.version,
|
||||
signature.id,
|
||||
signature.signedOn,
|
||||
signature.type,
|
||||
signature.jws,
|
||||
]);
|
||||
}
|
||||
if (data.meta) {
|
||||
for await (const [key, value] of Object.entries(data.meta)) {
|
||||
await client.query(`
|
||||
INSERT INTO audit_meta (
|
||||
audit_id,
|
||||
key,
|
||||
value
|
||||
) VALUES (
|
||||
$1,
|
||||
$2,
|
||||
$3
|
||||
);
|
||||
`, [
|
||||
res.rows[0].audit_id,
|
||||
key,
|
||||
value,
|
||||
]);
|
||||
}
|
||||
}
|
||||
await client.query('COMMIT');
|
||||
} catch(e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
await client.release();
|
||||
}
|
||||
}
|
||||
|
||||
async function getAuditsByAgreementId(client, id, offset, limit) {
|
||||
let ret = await client.query(`
|
||||
WITH origAudits AS (
|
||||
SELECT
|
||||
audit_id,
|
||||
version,
|
||||
agreement_id,
|
||||
hash_type,
|
||||
digest,
|
||||
created
|
||||
FROM audit
|
||||
WHERE agreement_id = $1
|
||||
ORDER BY created DESC
|
||||
OFFSET $2
|
||||
LIMIT $3
|
||||
),
|
||||
audits AS (
|
||||
SELECT
|
||||
a.*,
|
||||
JSON_AGG(json_build_object(
|
||||
'version', s.version,
|
||||
'id', s.id,
|
||||
'signedOn', s.signedOn,
|
||||
'type', s.type,
|
||||
'jws', s.jws
|
||||
)) AS signatures
|
||||
FROM
|
||||
origAudits a,
|
||||
audit_signature s
|
||||
WHERE s.audit_id = a.audit_id
|
||||
GROUP BY
|
||||
a.audit_id,
|
||||
a.version,
|
||||
a.agreement_id,
|
||||
a.hash_type,
|
||||
a.digest,
|
||||
a.created
|
||||
)
|
||||
SELECT
|
||||
JSON_AGG(json_build_object(
|
||||
'audit', json_build_object(
|
||||
'version', a.version,
|
||||
'agreementId', a.agreement_id,
|
||||
'hashType', a.hash_type,
|
||||
'digest', a.digest,
|
||||
'created', a.created
|
||||
),
|
||||
'signatures', a.signatures
|
||||
)) AS records
|
||||
FROM audits a
|
||||
`, [
|
||||
id,
|
||||
offset,
|
||||
limit,
|
||||
]);
|
||||
return ret;
|
||||
}
|
||||
|
||||
async function getAuditsByEventId(client, id, offset, limit) {
|
||||
let ret = await client.query(`
|
||||
WITH origAudits AS (
|
||||
SELECT
|
||||
audit_id,
|
||||
version,
|
||||
event_id,
|
||||
hash_type,
|
||||
digest,
|
||||
created
|
||||
FROM audit
|
||||
WHERE event_id = $1
|
||||
ORDER BY created DESC
|
||||
OFFSET $2
|
||||
LIMIT $3
|
||||
),
|
||||
audits AS (
|
||||
SELECT
|
||||
a.*,
|
||||
JSON_AGG(json_build_object(
|
||||
'version', s.version,
|
||||
'id', s.id,
|
||||
'signedOn', s.signedOn,
|
||||
'type', s.type,
|
||||
'jws', s.jws
|
||||
)) AS signatures
|
||||
FROM
|
||||
origAudits a,
|
||||
audit_signature s
|
||||
WHERE s.audit_id = a.audit_id
|
||||
GROUP BY
|
||||
a.audit_id,
|
||||
a.version,
|
||||
a.event_id,
|
||||
a.hash_type,
|
||||
a.digest,
|
||||
a.created
|
||||
)
|
||||
SELECT
|
||||
JSON_AGG(json_build_object(
|
||||
'audit', json_build_object(
|
||||
'version', a.version,
|
||||
'eventId', a.event_id,
|
||||
'hashType', a.hash_type,
|
||||
'digest', a.digest,
|
||||
'created', a.created
|
||||
),
|
||||
'signatures', a.signatures
|
||||
)) AS records
|
||||
FROM audits a
|
||||
`, [
|
||||
id,
|
||||
offset,
|
||||
limit,
|
||||
]);
|
||||
return ret;
|
||||
}
|
||||
|
||||
async function getAuditsByMeta(client, meta, offset, limit) {
|
||||
const fields = [offset, limit];
|
||||
let count = fields.length + 1;
|
||||
let whereInVals = ``
|
||||
for await (const [key, value] of Object.entries(meta)) {
|
||||
if (whereInVals != ``)
|
||||
whereInVals = ` AND `
|
||||
whereInVals += `(key = $${count++} AND value = $${count++})`;
|
||||
fields.push(key);
|
||||
fields.push(value);
|
||||
}
|
||||
let ret = await client.query(`
|
||||
WITH origAudits AS (
|
||||
SELECT
|
||||
audit_id,
|
||||
version,
|
||||
event_id,
|
||||
hash_type,
|
||||
digest,
|
||||
created
|
||||
FROM audit
|
||||
WHERE audit_id IN (
|
||||
SELECT audit_id
|
||||
FROM audit_meta
|
||||
WHERE ${whereInVals}
|
||||
)
|
||||
ORDER BY created DESC
|
||||
OFFSET $1
|
||||
LIMIT $2
|
||||
),
|
||||
audits AS (
|
||||
SELECT
|
||||
a.*,
|
||||
JSON_AGG(json_build_object(
|
||||
'version', s.version,
|
||||
'id', s.id,
|
||||
'signedOn', s.signedOn,
|
||||
'type', s.type,
|
||||
'jws', s.jws
|
||||
)) AS signatures
|
||||
FROM
|
||||
origAudits a,
|
||||
audit_signature s
|
||||
WHERE s.audit_id = a.audit_id
|
||||
GROUP BY
|
||||
a.audit_id,
|
||||
a.version,
|
||||
a.event_id,
|
||||
a.hash_type,
|
||||
a.digest,
|
||||
a.created
|
||||
)
|
||||
SELECT
|
||||
JSON_AGG(json_build_object(
|
||||
'audit', json_build_object(
|
||||
'version', a.version,
|
||||
'eventId', a.event_id,
|
||||
'hashType', a.hash_type,
|
||||
'digest', a.digest,
|
||||
'created', a.created
|
||||
),
|
||||
'signatures', a.signatures
|
||||
)) AS records
|
||||
FROM audits a
|
||||
`, fields);
|
||||
return ret;
|
||||
}
|
||||
|
||||
async function getAudits(eventId, agreementId, meta, offset) {
|
||||
let ret;
|
||||
let res;
|
||||
const limit = 100;
|
||||
const client = await getPool();
|
||||
try {
|
||||
if (eventId) {
|
||||
res = await getAuditsByEventId(client, eventId, offset, limit);
|
||||
} else if (agreementId) {
|
||||
res = await getAuditsByAgreementId(client, agreementId, offset, limit);
|
||||
} else {
|
||||
res = await getAuditsByMeta(client, meta, offset, limit);
|
||||
}
|
||||
if (res.rows.length > 0 && res.rows[0].records) {
|
||||
ret = {
|
||||
auditRecords: res.rows[0].records
|
||||
};
|
||||
if (ret.auditRecords.length === limit) {
|
||||
ret.nextPageToken = Buffer.from(`${offset + limit}`).toString("base64");
|
||||
}
|
||||
}
|
||||
} catch(e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
await client.release();
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
|
||||
async function put(input) {
|
||||
let res = {
|
||||
success: false,
|
||||
error: 'Unknown error',
|
||||
};
|
||||
try {
|
||||
const { audit, meta } = input;
|
||||
const error = getAuditErrors(audit);
|
||||
if (error) {
|
||||
res.error = error;
|
||||
console.log(`${prefix} - Bad Audit: ${error}`);
|
||||
} else {
|
||||
const id = audit.eventId ? audit.eventId : audit.agreementId;
|
||||
if (audit.eventId) {
|
||||
res.message = `event: ${audit.eventId}`;
|
||||
} else {
|
||||
res.message = `agreement: ${audit.agreementId}`;
|
||||
}
|
||||
await processAudit(input);
|
||||
res.success = true;
|
||||
delete res.error;
|
||||
}
|
||||
} catch (e) {
|
||||
if (e.message != 'audit record with id and digest exists')
|
||||
console.error(e);
|
||||
res.error = e.message;
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
async function get(input) {
|
||||
let res = {
|
||||
success: false,
|
||||
error: 'Unknown error',
|
||||
};
|
||||
try {
|
||||
let error;
|
||||
let id;
|
||||
let type;
|
||||
if (input.eventId) {
|
||||
id = input.eventId;
|
||||
type = 'event';
|
||||
error = isUuid(input.eventId) ? null : 'eventId invalid';
|
||||
} else if (input.agreementId) {
|
||||
id = input.agreementId;
|
||||
type = 'agreement';
|
||||
error = isUuid(input.agreementId) ? null : 'agreementId invalid';
|
||||
} else if (input.meta) {
|
||||
id = `<meta key/value>`;
|
||||
type = 'meta';
|
||||
} else {
|
||||
error = 'eventId, agreementId, or meta required';
|
||||
}
|
||||
if (error) {
|
||||
res.error = error;
|
||||
res.message = `bad request: ${error}`;
|
||||
} else {
|
||||
res.message = `requested ${type}: ${id}`;
|
||||
const offset = input.pageToken ? parseInt(Buffer.from(`${input.pageToken}`, "base64").toString('ascii')) : 0;
|
||||
let ret = await getAudits(input.eventId, input.agreementId, input.meta, offset);
|
||||
if (ret) {
|
||||
res.data = ret;
|
||||
res.success = true;
|
||||
delete res.error;
|
||||
} else {
|
||||
res.error = "not found"
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (e.message != 'audit record with id and digest exists')
|
||||
console.error(e);
|
||||
res.error = e.message;
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
export const archive = {
|
||||
put,
|
||||
get,
|
||||
}
|
||||
51
backend/modules/core/audit.js
Normal file
51
backend/modules/core/audit.js
Normal file
@@ -0,0 +1,51 @@
|
||||
import pkg from '@jlinc/core';
|
||||
const { JlincAudit } = pkg;
|
||||
|
||||
async function create(input) {
|
||||
const data = await JlincAudit.create(input);
|
||||
const message = data?.eventId
|
||||
? data?.eventId
|
||||
: data?.agreementId;
|
||||
return {
|
||||
data,
|
||||
message,
|
||||
}
|
||||
}
|
||||
|
||||
async function sign(input) {
|
||||
const data = await JlincAudit.sign(input);
|
||||
const message = data?.audit?.eventId
|
||||
? data?.audit?.eventId
|
||||
: data?.audit?.agreementId;
|
||||
return {
|
||||
data,
|
||||
message,
|
||||
}
|
||||
}
|
||||
|
||||
async function send(input) {
|
||||
const data = await JlincAudit.send(input);
|
||||
const message = data?.audit?.eventId
|
||||
? data?.audit?.eventId
|
||||
: data?.audit?.agreementId;
|
||||
return {
|
||||
data,
|
||||
message,
|
||||
}
|
||||
}
|
||||
|
||||
async function get(input) {
|
||||
const data = await JlincAudit.get(input);
|
||||
const message = 'auditGet';
|
||||
return {
|
||||
data,
|
||||
message,
|
||||
}
|
||||
}
|
||||
|
||||
export const audit = {
|
||||
create,
|
||||
sign,
|
||||
send,
|
||||
get,
|
||||
}
|
||||
558
backend/modules/core/data/agreement.js
Normal file
558
backend/modules/core/data/agreement.js
Normal file
@@ -0,0 +1,558 @@
|
||||
import pkg from '@jlinc/core';
|
||||
const { JlincAgreement, JlincAudit } = pkg;
|
||||
import { getPool } from "../../../db/index.js";
|
||||
import { entity } from "./entity.js";
|
||||
import { putQueue } from "../../../common/queue.js";
|
||||
import { cacheAgreementMarkdown } from "../../../util/cacheAgreement.js";
|
||||
|
||||
async function getAgreement(client, userId, id, key) {
|
||||
const whereClause = id ? `a.agreement_id_uuid = $1` : `a.agreement_id_uuid = (SELECT agreement_uuid FROM agreement_content WHERE lower(key) = lower($1))`;
|
||||
const whereValue = id ? id : key;
|
||||
const sql = `
|
||||
SELECT
|
||||
JSON_BUILD_OBJECT(
|
||||
'version', a.version,
|
||||
'@context', a.context,
|
||||
'parent', a.parent,
|
||||
'references', (
|
||||
SELECT JSON_AGG(r.uri ORDER BY r.uri)
|
||||
FROM reference r
|
||||
INNER JOIN agreement_reference ar
|
||||
ON r.id = ar.reference_id
|
||||
AND ar.agreement_id = a.id
|
||||
AND (
|
||||
(r.user_id = a.user_id AND ar.user_id = a.user_id)
|
||||
OR
|
||||
(r.user_id IS NULL AND ar.user_id IS NULL AND a.user_id IS NULL)
|
||||
)
|
||||
),
|
||||
'agreementId', a.agreement_id_uuid,
|
||||
'created', a.created,
|
||||
'ids', (
|
||||
SELECT JSON_AGG(ari.required_id)
|
||||
FROM agreement_required_id ari
|
||||
WHERE ari.agreement_id = a.id
|
||||
),
|
||||
'purposes', (
|
||||
SELECT JSON_AGG(p.value ORDER BY p.value)
|
||||
FROM purpose p
|
||||
INNER JOIN agreement_purpose ap
|
||||
ON p.id = ap.purpose_id
|
||||
AND ap.agreement_id = a.id
|
||||
AND (
|
||||
(p.user_id = a.user_id AND ap.user_id = a.user_id)
|
||||
OR
|
||||
(p.user_id IS NULL AND ap.user_id IS NULL AND a.user_id IS NULL)
|
||||
)
|
||||
),
|
||||
'prohibitions', (
|
||||
SELECT JSON_AGG(c.value ORDER BY c.value)
|
||||
FROM prohibition c
|
||||
INNER JOIN agreement_prohibition ac
|
||||
ON c.id = ac.prohibition_id
|
||||
AND ac.agreement_id = a.id
|
||||
AND (
|
||||
(c.user_id = ac.user_id AND ac.user_id = a.user_id)
|
||||
OR
|
||||
(c.user_id IS NULL AND ac.user_id IS NULL AND a.user_id IS NULL)
|
||||
)
|
||||
),
|
||||
'validRoles', (
|
||||
SELECT JSON_AGG(r.value ORDER BY r.value)
|
||||
FROM role r
|
||||
INNER JOIN agreement_role ar
|
||||
ON r.id = ar.role_id
|
||||
AND ar.agreement_id = a.id
|
||||
AND (
|
||||
(r.user_id = ar.user_id AND ar.user_id = a.user_id)
|
||||
OR
|
||||
(r.user_id IS NULL AND ar.user_id IS NULL AND a.user_id IS NULL)
|
||||
)
|
||||
)
|
||||
) AS record
|
||||
FROM agreement a
|
||||
WHERE ${whereClause}
|
||||
AND (
|
||||
(
|
||||
a.user_id = $2
|
||||
OR a.user_id IS NULL
|
||||
)
|
||||
OR a.public IS TRUE
|
||||
)
|
||||
`
|
||||
const res = await client.query(sql, [
|
||||
whereValue,
|
||||
userId,
|
||||
]);
|
||||
if (res.rows.length > 0 && res.rows[0].record) {
|
||||
const ret = res.rows[0].record;
|
||||
if (!ret.ids) ret.ids = [];
|
||||
if (!ret.purposes) ret.purposes = [];
|
||||
if (!ret.prohibitions) ret.prohibitions = [];
|
||||
if (!ret.validRoles) ret.validRoles = [];
|
||||
if (ret.version == 1) {
|
||||
delete ret.references;
|
||||
} else {
|
||||
delete ret["@context"];
|
||||
delete ret.parent;
|
||||
ret.permitted = ret.purposes;
|
||||
delete ret.purposes;
|
||||
ret.prohibited = ret.prohibitions;
|
||||
delete ret.prohibitions;
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function getSignatures(client, userId, id) {
|
||||
const sql = `
|
||||
SELECT
|
||||
JSON_AGG(
|
||||
JSON_BUILD_OBJECT(
|
||||
'version', s.version,
|
||||
'id', s.signer_id,
|
||||
'signedOn', s.signed_on,
|
||||
'type', s.type,
|
||||
'jws', s.jws,
|
||||
'role', (
|
||||
SELECT r.value
|
||||
FROM role r
|
||||
WHERE r.id = s.role_id
|
||||
AND r.user_id = $2
|
||||
)
|
||||
)
|
||||
) AS records
|
||||
FROM signature s
|
||||
INNER JOIN agreement a ON s.agreement_id = a.id
|
||||
WHERE a.agreement_id_uuid = $1
|
||||
AND a.user_id = $2;
|
||||
`
|
||||
const res = await client.query(sql, [
|
||||
id,
|
||||
userId,
|
||||
]);
|
||||
if (res.rows.length > 0 && res.rows[0].records) {
|
||||
return res.rows[0].records;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function get(input, userId) {
|
||||
let response = {
|
||||
success: false,
|
||||
error: 'Unknown error',
|
||||
};
|
||||
const client = await getPool();
|
||||
try {
|
||||
const existingAgreement = await getAgreement(client, userId, input.agreementId, input.key)
|
||||
if (!existingAgreement) {
|
||||
response.error = 'agreement not found'
|
||||
} else {
|
||||
const data = {
|
||||
agreement: existingAgreement
|
||||
}
|
||||
if (input.includeSignatures) {
|
||||
data.signatures = await getSignatures(client, userId, input.agreementId)
|
||||
for (let x = 0; x < data.signatures.length; x++) {
|
||||
if (data.signatures[x].role === null) {
|
||||
delete data.signatures[x].role;
|
||||
}
|
||||
}
|
||||
}
|
||||
response = {
|
||||
message: `retrieved: ${input.agreementId}`,
|
||||
data,
|
||||
}
|
||||
}
|
||||
} catch(e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
await client.release();
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
async function save(client, userId, agreement, publicAgreement) {
|
||||
await client.query(`BEGIN`);
|
||||
const isPublic = publicAgreement ? true : false;
|
||||
const res = await client.query(`
|
||||
INSERT INTO agreement (
|
||||
user_id,
|
||||
version,
|
||||
parent,
|
||||
agreement_id_uuid,
|
||||
context,
|
||||
public,
|
||||
created,
|
||||
created_as_ts
|
||||
) VALUES (
|
||||
$1,
|
||||
$2,
|
||||
$3,
|
||||
$4,
|
||||
$5,
|
||||
$6,
|
||||
$7,
|
||||
$8
|
||||
) RETURNING id;
|
||||
`, [
|
||||
userId,
|
||||
agreement.version,
|
||||
agreement.parent,
|
||||
agreement.agreementId,
|
||||
agreement["@context"],
|
||||
isPublic,
|
||||
agreement.created,
|
||||
new Date(agreement.created).toISOString(),
|
||||
]);
|
||||
for (const id of agreement.ids) {
|
||||
await client.query(`
|
||||
INSERT INTO agreement_required_id (
|
||||
user_id,
|
||||
agreement_id,
|
||||
required_id
|
||||
) VALUES (
|
||||
$1,
|
||||
$2,
|
||||
$3
|
||||
);
|
||||
`, [
|
||||
userId,
|
||||
res.rows[0].id,
|
||||
id,
|
||||
]);
|
||||
}
|
||||
if (agreement.references) {
|
||||
for (const reference of agreement.references) {
|
||||
await client.query(`
|
||||
INSERT INTO reference (
|
||||
user_id,
|
||||
uri
|
||||
) VALUES (
|
||||
$1,
|
||||
$2
|
||||
) ON CONFLICT DO NOTHING;
|
||||
`, [
|
||||
userId,
|
||||
reference,
|
||||
]);
|
||||
await client.query(`
|
||||
INSERT INTO agreement_reference (
|
||||
user_id,
|
||||
agreement_id,
|
||||
reference_id
|
||||
) VALUES (
|
||||
$1,
|
||||
$2,
|
||||
(
|
||||
SELECT id
|
||||
FROM reference
|
||||
WHERE uri = $3
|
||||
AND user_id ${userId ? `= $1` : `IS NULL`}
|
||||
)
|
||||
);
|
||||
`, [
|
||||
userId,
|
||||
res.rows[0].id,
|
||||
reference,
|
||||
]);
|
||||
}
|
||||
}
|
||||
const purposes = agreement.version == 1 ? agreement.purposes : agreement.permitted;
|
||||
for (const purpose of purposes) {
|
||||
await client.query(`
|
||||
INSERT INTO purpose (
|
||||
user_id,
|
||||
value
|
||||
) VALUES (
|
||||
$1,
|
||||
$2
|
||||
) ON CONFLICT DO NOTHING;
|
||||
`, [
|
||||
userId,
|
||||
purpose,
|
||||
]);
|
||||
await client.query(`
|
||||
INSERT INTO agreement_purpose (
|
||||
user_id,
|
||||
agreement_id,
|
||||
purpose_id
|
||||
) VALUES (
|
||||
$1,
|
||||
$2,
|
||||
(
|
||||
SELECT id
|
||||
FROM purpose
|
||||
WHERE value = $3
|
||||
AND user_id ${userId ? `= $1` : `IS NULL`}
|
||||
)
|
||||
);
|
||||
`, [
|
||||
userId,
|
||||
res.rows[0].id,
|
||||
purpose,
|
||||
]);
|
||||
}
|
||||
const prohibitions = agreement.version == 1 ? agreement.prohibitions : agreement.prohibited;
|
||||
for (const prohibition of prohibitions) {
|
||||
await client.query(`
|
||||
INSERT INTO prohibition (
|
||||
user_id,
|
||||
value
|
||||
) VALUES (
|
||||
$1,
|
||||
$2
|
||||
) ON CONFLICT DO NOTHING;
|
||||
`, [
|
||||
userId,
|
||||
prohibition,
|
||||
]);
|
||||
await client.query(`
|
||||
INSERT INTO agreement_prohibition (
|
||||
user_id,
|
||||
agreement_id,
|
||||
prohibition_id
|
||||
) VALUES (
|
||||
$1,
|
||||
$2,
|
||||
(
|
||||
SELECT id
|
||||
FROM prohibition
|
||||
WHERE value = $3
|
||||
AND user_id ${userId ? `= $1` : `IS NULL`}
|
||||
)
|
||||
);
|
||||
`, [
|
||||
userId,
|
||||
res.rows[0].id,
|
||||
prohibition,
|
||||
]);
|
||||
}
|
||||
for (const role of agreement.validRoles) {
|
||||
await client.query(`
|
||||
INSERT INTO role (
|
||||
user_id,
|
||||
value
|
||||
) VALUES (
|
||||
$1,
|
||||
$2
|
||||
) ON CONFLICT DO NOTHING;
|
||||
`, [
|
||||
userId,
|
||||
role,
|
||||
]);
|
||||
await client.query(`
|
||||
INSERT INTO agreement_role (
|
||||
user_id,
|
||||
agreement_id,
|
||||
role_id
|
||||
) VALUES (
|
||||
$1,
|
||||
$2,
|
||||
(
|
||||
SELECT id
|
||||
FROM role
|
||||
WHERE value = $3
|
||||
AND user_id ${userId ? `= $1` : `IS NULL`}
|
||||
)
|
||||
);
|
||||
`, [
|
||||
userId,
|
||||
res.rows[0].id,
|
||||
role,
|
||||
]);
|
||||
}
|
||||
await client.query('COMMIT');
|
||||
}
|
||||
|
||||
async function saveSignatures(client, userId, agreementId, signatures) {
|
||||
await client.query(`BEGIN`);
|
||||
for (const signature of signatures) {
|
||||
await client.query(`
|
||||
INSERT INTO signature (
|
||||
user_id,
|
||||
version,
|
||||
signer_id,
|
||||
signed_on,
|
||||
type,
|
||||
jws,
|
||||
role_id,
|
||||
agreement_id,
|
||||
event_id,
|
||||
signed_on_as_ts
|
||||
) VALUES (
|
||||
$1,
|
||||
$2,
|
||||
$3,
|
||||
$4,
|
||||
$5,
|
||||
$6,
|
||||
(
|
||||
SELECT id
|
||||
FROM role
|
||||
WHERE value = $7
|
||||
AND user_id = $1
|
||||
),
|
||||
(
|
||||
SELECT id
|
||||
FROM agreement
|
||||
WHERE agreement_id_uuid = $8
|
||||
AND user_id = $1
|
||||
),
|
||||
$9,
|
||||
$10
|
||||
) ON CONFLICT DO NOTHING;
|
||||
`, [
|
||||
userId,
|
||||
signature.version,
|
||||
signature.id,
|
||||
signature.signedOn,
|
||||
signature.type,
|
||||
signature.jws,
|
||||
signature.role,
|
||||
agreementId,
|
||||
null,
|
||||
new Date(signature.signedOn).toISOString(),
|
||||
]);
|
||||
}
|
||||
await client.query('COMMIT');
|
||||
}
|
||||
|
||||
async function create(input, userId, _client, uuid) {
|
||||
let response = {
|
||||
success: false,
|
||||
error: 'Unknown error',
|
||||
};
|
||||
const client = _client || await getPool();
|
||||
try {
|
||||
input.didDocs = [];
|
||||
for (const shortName of input.shortNames) {
|
||||
input.didDocs.push((await entity.getEntity(client, userId, shortName)).didDoc)
|
||||
}
|
||||
delete input.shortNames;
|
||||
if (input.caveats) {
|
||||
input.prohibitions = input.caveats;
|
||||
delete input.caveats;
|
||||
}
|
||||
const agreement = await JlincAgreement.create(input);
|
||||
if (uuid) {
|
||||
agreement.agreementId = uuid;
|
||||
}
|
||||
await save(client, userId, agreement, input.public);
|
||||
for (const uri of (agreement.references || [])) {
|
||||
await cacheAgreementMarkdown({ userId, agreementUuid: agreement.agreementId, uri }, client);
|
||||
}
|
||||
response = {
|
||||
message: `created and saved: ${agreement.agreementId}`,
|
||||
data: agreement,
|
||||
}
|
||||
} catch(e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
if (!_client)
|
||||
await client.release();
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
async function process(input, userId, _client, _agreement) {
|
||||
let response = {
|
||||
success: false,
|
||||
error: 'Unknown error',
|
||||
};
|
||||
const client = _client || await getPool();
|
||||
try {
|
||||
const existingAgreement = _agreement || await getAgreement(client, userId, input.agreementId)
|
||||
if (!existingAgreement) {
|
||||
throw new Error('agreement does not exist')
|
||||
}
|
||||
const inputEntity = input.shortName ? await entity.getEntity(client, userId, input.shortName) : null;
|
||||
const didDoc = inputEntity ? inputEntity.didDoc : input.didDoc;
|
||||
const signingKey = inputEntity ? inputEntity.controlPrivateKeyB64U : input.signingKey;
|
||||
const signingPublicKey = inputEntity ? inputEntity.didDoc.verificationMethod[0].key : input.signingPublicKey;
|
||||
const signingInput = {
|
||||
agreement: existingAgreement,
|
||||
didDoc,
|
||||
signingKey,
|
||||
signingPublicKey,
|
||||
role: input.role,
|
||||
}
|
||||
const agreementData = await JlincAgreement.sign(signingInput);
|
||||
await saveSignatures(client, userId, existingAgreement.agreementId, agreementData.signatures);
|
||||
const audit = await JlincAudit.create(agreementData);
|
||||
const auditInput = {
|
||||
audit,
|
||||
didDoc,
|
||||
signingKey,
|
||||
signingPublicKey,
|
||||
}
|
||||
const auditData = await JlincAudit.sign(auditInput);
|
||||
response = {
|
||||
message: `signed and saved: ${agreementData?.agreement?.agreementId}`,
|
||||
data: {
|
||||
auditData,
|
||||
},
|
||||
}
|
||||
if (input.archive) {
|
||||
await putQueue(
|
||||
client,
|
||||
'audit',
|
||||
`${input.archive.url}/api/v1/audit/put`,
|
||||
{
|
||||
'Authorization': `Bearer ${input.archive.key}`,
|
||||
},
|
||||
response.data.auditData,
|
||||
)
|
||||
}
|
||||
} catch(e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
if (!_client)
|
||||
await client.release();
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
async function produce(input, userId) {
|
||||
let response = {
|
||||
success: false,
|
||||
error: 'Unknown error',
|
||||
};
|
||||
const client = await getPool();
|
||||
try {
|
||||
const created = (await create(input.data, userId, client)).data;
|
||||
const processed = (await process(
|
||||
{
|
||||
agreementId: created.agreementId,
|
||||
shortName: input.shortName,
|
||||
role: input.role,
|
||||
archive: input.archive,
|
||||
},
|
||||
userId,
|
||||
client,
|
||||
created,
|
||||
)).data;
|
||||
response = {
|
||||
message: `created and processed: ${created.agreementId}`,
|
||||
data: {
|
||||
created,
|
||||
processed,
|
||||
},
|
||||
}
|
||||
} catch(e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
await client.release();
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
export const agreement = {
|
||||
getAgreement,
|
||||
getSignatures,
|
||||
get,
|
||||
create,
|
||||
process,
|
||||
produce,
|
||||
}
|
||||
259
backend/modules/core/data/audit.js
Normal file
259
backend/modules/core/data/audit.js
Normal file
@@ -0,0 +1,259 @@
|
||||
import { getPool } from "../../../db/index.js";
|
||||
import { agreement } from "./agreement.js";
|
||||
import { event } from "./event.js";
|
||||
import { stringify, configure } from "safe-stable-stringify";
|
||||
import { createHash } from "crypto";
|
||||
import { entity } from "./entity.js";
|
||||
import sodium from "sodium-native";
|
||||
|
||||
|
||||
function splitJws(jws) {
|
||||
const sections = jws.split('.');
|
||||
if (sections.length !== 3) {
|
||||
throw ('Input must be a JWS.');
|
||||
}
|
||||
const jwt = JSON.parse(Buffer.from(sections[0], 'base64url').toString());
|
||||
if (jwt.alg !== 'EdDSA') {
|
||||
throw ('JWT does not indicate EdDSA');
|
||||
}
|
||||
const payload = JSON.parse(Buffer.from(sections[1], 'base64url').toString());
|
||||
const wasSigned = Buffer.from(sections[0] + '.' + sections[1]);
|
||||
const signature = Buffer.from(sections[2], 'base64url');
|
||||
return {
|
||||
jwt,
|
||||
payload,
|
||||
wasSigned,
|
||||
signature,
|
||||
}
|
||||
}
|
||||
|
||||
function verifyJws(input) {
|
||||
let ret = false;
|
||||
try {
|
||||
if (!input.jws) {
|
||||
throw ('No JWS provided.');
|
||||
}
|
||||
if (!input.publicKey) {
|
||||
throw ('No publicKey provided.');
|
||||
}
|
||||
const publicKey = Buffer.from(input.publicKey, 'base64url');
|
||||
if (publicKey.length !== sodium.crypto_sign_PUBLICKEYBYTES) {
|
||||
throw ('publicKey length must be crypto_sign_PUBLICKEYBYTES (32).');
|
||||
}
|
||||
const providedPublicKey = Buffer.from(input.jws.jwt.jwk.x, 'base64url');
|
||||
if (publicKey.compare(providedPublicKey) !== 0) {
|
||||
ret = false;
|
||||
} else {
|
||||
ret = sodium.crypto_sign_verify_detached(input.jws.signature, input.jws.wasSigned, Buffer.from(input.publicKey, 'base64url'));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
return ret;
|
||||
};
|
||||
|
||||
function validateSignatures(item, signatures, didDocs) {
|
||||
let res = false;
|
||||
try {
|
||||
for (const signature of signatures) {
|
||||
const didDoc = didDocs.find((didDoc) => didDoc.id === signature.id);
|
||||
if (!didDoc) throw ('DID Document not provided');
|
||||
const vmCreatedDate = new Date(didDoc.verificationMethod[0].created);
|
||||
const signDate = new Date(signature.signedOn)
|
||||
const vmDeactivatedDate = didDoc.verificationMethod[0].deactivated ? new Date(didDoc.verificationMethod[0].deactivated) : null;
|
||||
const validDate = signDate >= vmCreatedDate && (!vmDeactivatedDate || vmDeactivatedDate >= signDate);
|
||||
if (!validDate) throw ('Signature is not valid due to key dates');
|
||||
const split = splitJws(signature.jws);
|
||||
if (stringify(item) !== stringify(split.payload)) throw ('Payload does not match');
|
||||
const validJws = verifyJws({
|
||||
jws: split,
|
||||
publicKey: didDoc.verificationMethod[0].key,
|
||||
});
|
||||
if (!validJws) throw ('Signature is not valid');
|
||||
res = true;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
function validateSignedBefore(item, signatures) {
|
||||
let res = false;
|
||||
try {
|
||||
let issue = false;
|
||||
for (const signature of signatures) {
|
||||
if (signature.signedOn >= item.created) {
|
||||
issue = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
res = !issue;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
function validateDidsMatch(auditSigs, targetSigs) {
|
||||
let match = true;
|
||||
for (const asig of auditSigs) {
|
||||
let found = false;
|
||||
for (const tsig of targetSigs) {
|
||||
if (tsig.id === asig.id) {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!found) {
|
||||
match = false;
|
||||
}
|
||||
}
|
||||
return match;
|
||||
}
|
||||
|
||||
function generateDigest(content, length) {
|
||||
if (typeof content === 'object') {
|
||||
content = stringify(content);
|
||||
}
|
||||
const hash = createHash('sha256')
|
||||
.update(content)
|
||||
.digest('hex')
|
||||
.slice(0, length);
|
||||
return hash;
|
||||
}
|
||||
|
||||
async function verify(input, userId) {
|
||||
let response = {
|
||||
success: false,
|
||||
error: 'Unknown error',
|
||||
};
|
||||
const client = await getPool();
|
||||
try {
|
||||
const data = {
|
||||
valid: [],
|
||||
invalid: [],
|
||||
};
|
||||
if (!input.didDocs) {
|
||||
input.didDocs = [];
|
||||
for (const shortName of input.shortNames || []) {
|
||||
input.didDocs.push((await entity.getEntity(client, userId, shortName)).didDoc)
|
||||
}
|
||||
}
|
||||
delete input.shortNames;
|
||||
const organizedById = [];
|
||||
for (const item of input.audits) {
|
||||
const found = organizedById.find((obi) =>
|
||||
item.audit.agreementId && obi.agreementId === item.audit.agreementId ||
|
||||
item.audit.eventId && obi.eventId === item.audit.eventId
|
||||
);
|
||||
if (!found) {
|
||||
const newItem = item.audit.agreementId
|
||||
? {
|
||||
agreementId: item.audit.agreementId,
|
||||
auditRecords: [item],
|
||||
}
|
||||
: {
|
||||
eventId: item.audit.eventId,
|
||||
auditRecords: [item],
|
||||
}
|
||||
organizedById.push(newItem)
|
||||
} else {
|
||||
found.auditRecords.push(item);
|
||||
}
|
||||
}
|
||||
for (const item of organizedById) {
|
||||
const existingItem = item.eventId
|
||||
? await event.getEvent(client, userId, item.eventId, true)
|
||||
: await agreement.getAgreement(client, userId, item.agreementId)
|
||||
const existingSignatures = item.eventId
|
||||
? await event.getSignatures(client, userId, item.eventId)
|
||||
: await agreement.getSignatures(client, userId, item.agreementId)
|
||||
// Does the agreement/event signature verify?
|
||||
let validSignature = false;
|
||||
if (validateSignatures(existingItem, existingSignatures, input.didDocs)) {
|
||||
validSignature = true;
|
||||
}
|
||||
for (const auditRecord of item.auditRecords) {
|
||||
const res = {
|
||||
audit: auditRecord,
|
||||
results: {
|
||||
validId: false,
|
||||
validSignature,
|
||||
validAuditHash: false,
|
||||
validAuditSignature: false,
|
||||
}
|
||||
}
|
||||
// Do the agreement/event IDs match?
|
||||
if (
|
||||
(item.agreementId !== null && auditRecord.audit.agreementId === item.agreementId) ||
|
||||
(item.eventId !== null && auditRecord.audit.eventId === item.eventId)
|
||||
)
|
||||
res.results.validId = true;
|
||||
// Do DID IDs match between audit and target object?
|
||||
res.results.validMatchingDids = validateDidsMatch(auditRecord.signatures, existingSignatures);
|
||||
// Does the audit hash match?
|
||||
// The digest was created from whichever signatures this audit record has
|
||||
const signatures = [];
|
||||
for (const s of auditRecord.signatures) {
|
||||
const existingSignature = existingSignatures.find((es) => es.id === s.id);
|
||||
if (existingSignature) {
|
||||
signatures.push(existingSignature)
|
||||
}
|
||||
}
|
||||
const check = item.eventId
|
||||
? {
|
||||
event: existingItem,
|
||||
signatures,
|
||||
}
|
||||
: {
|
||||
agreement: existingItem,
|
||||
signatures,
|
||||
}
|
||||
const digest = generateDigest(check);
|
||||
if (digest === auditRecord.audit.digest)
|
||||
res.results.validAuditHash = true;
|
||||
// Does the audit signature verify?
|
||||
if (validateSignatures(auditRecord.audit, auditRecord.signatures, input.didDocs)) {
|
||||
res.results.validAuditSignature = true;
|
||||
}
|
||||
const isValid =
|
||||
res.results.validId === true &&
|
||||
res.results.validSignature === true &&
|
||||
res.results.validAuditHash === true &&
|
||||
res.results.validAuditSignature === true;
|
||||
if (isValid) {
|
||||
data.valid.push(res);
|
||||
} else {
|
||||
data.invalid.push(res);
|
||||
}
|
||||
// If an event, has the DID signed the agreement and is that signature valid?
|
||||
if (existingItem.eventId !== null && existingItem.agreementId !== '00000000-0000-0000-0000-000000000000') {
|
||||
const existingAgreement = await agreement.getAgreement(client, userId, existingItem.agreementId);
|
||||
const existingAgreementSignatures = await agreement.getSignatures(client, userId, existingItem.agreementId);
|
||||
res.results.validEventAgreement = validateDidsMatch(auditRecord.signatures, existingAgreementSignatures);
|
||||
res.results.validEventAgreementSignature = false;
|
||||
if (validateSignatures(existingAgreement, existingAgreementSignatures, input.didDocs) && validateSignedBefore(existingItem, existingAgreementSignatures)) {
|
||||
res.results.validEventAgreementSignature = true;
|
||||
}
|
||||
} else {
|
||||
res.results.validEventAgreement = true;
|
||||
res.results.validEventAgreementSignature = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
response = {
|
||||
message: 'validation complete',
|
||||
data,
|
||||
};
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
await client.release();
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
export const audit = {
|
||||
verify,
|
||||
}
|
||||
172
backend/modules/core/data/entity.js
Normal file
172
backend/modules/core/data/entity.js
Normal file
@@ -0,0 +1,172 @@
|
||||
import { getPool } from "../../../db/index.js";
|
||||
import { createHash } from "crypto";
|
||||
import { getConfig } from "../../../common/config.js";
|
||||
import axios from "axios";
|
||||
import sodium from "sodium-native";
|
||||
|
||||
async function getEntity(client, userId, shortName) {
|
||||
const sql = `
|
||||
SELECT
|
||||
JSON_BUILD_OBJECT(
|
||||
'fedidUrl', e.fedid_url,
|
||||
'shortName', e.short_name,
|
||||
'didId', e.did_id,
|
||||
'controlPrivateKeyB64U', e.control_private_key_b64u,
|
||||
'recoveryPrivateKeyB64U', e.recovery_private_key_b64u,
|
||||
'didDoc', e.did_doc
|
||||
) AS record
|
||||
FROM entity e
|
||||
WHERE LOWER(e.short_name) = LOWER($1)
|
||||
AND e.user_id = $2;
|
||||
`
|
||||
const res = await client.query(sql, [
|
||||
shortName,
|
||||
userId,
|
||||
]);
|
||||
if (res.rows.length > 0 && res.rows[0].record) {
|
||||
return res.rows[0].record;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function get(input, userId) {
|
||||
let response = {
|
||||
success: false,
|
||||
error: 'Unknown error',
|
||||
};
|
||||
const client = await getPool();
|
||||
try {
|
||||
const existingEntity = await getEntity(client, userId, input.shortName)
|
||||
if (!existingEntity) {
|
||||
response.error = 'entity not found'
|
||||
} else {
|
||||
const data = {
|
||||
didDoc: existingEntity.didDoc,
|
||||
}
|
||||
response = {
|
||||
message: `retrieved: ${input.shortName}`,
|
||||
data,
|
||||
}
|
||||
}
|
||||
} catch(e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
await client.release();
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
async function getDomains(input, userId) {
|
||||
let response = {
|
||||
success: false,
|
||||
error: 'Unknown error',
|
||||
};
|
||||
try {
|
||||
const config = getConfig();
|
||||
const fedidUrl = input.fedidUrl ?? config.defaultFedidUrl;
|
||||
const data = (await axios.get(
|
||||
`${fedidUrl}/api/v2/domains`,
|
||||
)).data.data.domains;
|
||||
response = {
|
||||
message: `retrieved domains`,
|
||||
data,
|
||||
}
|
||||
} catch(e) {
|
||||
console.error(e)
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
async function save(client, userId, entity) {
|
||||
const res = await client.query(`
|
||||
INSERT INTO entity (
|
||||
user_id,
|
||||
fedid_url,
|
||||
short_name,
|
||||
did_id,
|
||||
control_private_key_b64u,
|
||||
recovery_private_key_b64u,
|
||||
did_doc
|
||||
) VALUES (
|
||||
$1,
|
||||
$2,
|
||||
$3,
|
||||
$4,
|
||||
$5,
|
||||
$6,
|
||||
$7
|
||||
) RETURNING id;
|
||||
`, [
|
||||
userId,
|
||||
entity.fedidUrl,
|
||||
entity.shortName,
|
||||
entity.didDoc.id,
|
||||
entity.controlPrivateKeyB64U,
|
||||
entity.recoveryPrivateKeyB64U,
|
||||
entity.didDoc,
|
||||
]);
|
||||
}
|
||||
|
||||
async function create(input, userId) {
|
||||
let response = {
|
||||
success: false,
|
||||
error: 'Unknown error',
|
||||
};
|
||||
const client = await getPool();
|
||||
try {
|
||||
const config = getConfig();
|
||||
const fedidUrl = input.fedidUrl ?? config.defaultFedidUrl;
|
||||
const domains = (await axios.get(
|
||||
`${fedidUrl}/api/v2/domains`,
|
||||
)).data.data.domains;
|
||||
const shortName = input.shortName.split('@')
|
||||
const domain = shortName[1]
|
||||
if (!domains.includes(domain)) {
|
||||
throw new Error('domain is not available');
|
||||
}
|
||||
const entity = {
|
||||
shortName: input.shortName,
|
||||
fedidUrl,
|
||||
}
|
||||
// Generate a control key
|
||||
entity.controlPublicKey = Buffer.alloc(sodium.crypto_sign_PUBLICKEYBYTES);
|
||||
entity.controlPrivateKey = Buffer.alloc(sodium.crypto_sign_SECRETKEYBYTES);
|
||||
sodium.crypto_sign_keypair(entity.controlPublicKey, entity.controlPrivateKey);
|
||||
entity.controlPublicKeyB64U = entity.controlPublicKey.toString("base64url");
|
||||
entity.controlPrivateKeyB64U = entity.controlPrivateKey.toString("base64url");
|
||||
// Generate a recovery key
|
||||
entity.recoveryPublicKey = Buffer.alloc(sodium.crypto_sign_PUBLICKEYBYTES);
|
||||
entity.recoveryPrivateKey = Buffer.alloc(sodium.crypto_sign_SECRETKEYBYTES);
|
||||
entity.recoveryPrivateKeyB64U = entity.recoveryPrivateKey.toString("base64url");
|
||||
sodium.crypto_sign_keypair(entity.recoveryPublicKey, entity.recoveryPrivateKey);
|
||||
entity.recoveryHash = createHash("sha256").update(entity.recoveryPublicKey).digest("hex").slice(0, 48);
|
||||
entity.didDoc = (await axios.post(
|
||||
`${fedidUrl}/api/v2/did/create`,
|
||||
{
|
||||
shortName: entity.shortName,
|
||||
control: entity.controlPublicKeyB64U,
|
||||
recoveryHash: entity.recoveryHash,
|
||||
},
|
||||
)).data.data.didDoc;
|
||||
await save(client, userId, entity);
|
||||
response = {
|
||||
message: `created and saved: ${entity.didDoc.id}`,
|
||||
data: {
|
||||
didDoc: entity.didDoc,
|
||||
},
|
||||
}
|
||||
} catch(e) {
|
||||
console.error(e);
|
||||
response.error = e.message;
|
||||
} finally {
|
||||
await client.release();
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
export const entity = {
|
||||
getEntity,
|
||||
getDomains,
|
||||
get,
|
||||
create,
|
||||
}
|
||||
413
backend/modules/core/data/event.js
Normal file
413
backend/modules/core/data/event.js
Normal file
@@ -0,0 +1,413 @@
|
||||
import pkg from '@jlinc/core';
|
||||
const { JlincEvent, JlincAudit } = pkg;
|
||||
import { getPool } from "../../../db/index.js";
|
||||
import { entity } from "./entity.js";
|
||||
import { putQueue } from "../../../common/queue.js";
|
||||
import { cacheAgreementMarkdown } from "../../../util/cacheAgreement.js";
|
||||
|
||||
async function getEvent(client, userId, id, includeData, meta) {
|
||||
const dataSql = includeData
|
||||
? `
|
||||
'data', (
|
||||
SELECT ed.data
|
||||
FROM event_data ed
|
||||
WHERE ed.event_id = e.id
|
||||
),
|
||||
`
|
||||
: ``;
|
||||
let fields = [userId];
|
||||
let count = fields.length + 1;
|
||||
let whereAnd = ``
|
||||
if (id) {
|
||||
whereAnd += ` AND e.event_id_uuid = $${count++}`;
|
||||
fields.push(id);
|
||||
}
|
||||
if (meta) {
|
||||
let whereInVals = ``
|
||||
for await (const [key, value] of Object.entries(meta)) {
|
||||
if (whereInVals != ``)
|
||||
whereInVals = ` AND `
|
||||
whereInVals += `(em.key = $${count++} AND em.value = $${count++})`;
|
||||
fields.push(key);
|
||||
fields.push(value);
|
||||
}
|
||||
whereAnd += `
|
||||
AND e.id IN (
|
||||
SELECT em.event_id
|
||||
FROM event_meta em
|
||||
WHERE em.user_id = $1
|
||||
AND ${whereInVals}
|
||||
)
|
||||
`
|
||||
}
|
||||
const sql = `
|
||||
SELECT
|
||||
JSON_BUILD_OBJECT(
|
||||
'version', e.version,
|
||||
'eventId', e.event_id_uuid,
|
||||
'type', (
|
||||
SELECT et.value
|
||||
FROM event_type et
|
||||
WHERE et.id = e.event_type_id
|
||||
),
|
||||
'senderId', e.sender_id,
|
||||
'recipientId', e.recipient_id,
|
||||
'created', e.created,
|
||||
'agreementId', (
|
||||
SELECT a.agreement_id_uuid
|
||||
FROM agreement a
|
||||
WHERE a.id = e.agreement_id
|
||||
AND a.user_id = $1
|
||||
),
|
||||
${dataSql}
|
||||
'created', e.created
|
||||
) AS record
|
||||
FROM event e
|
||||
WHERE e.user_id = $1
|
||||
${whereAnd};
|
||||
`
|
||||
const res = await client.query(sql, fields);
|
||||
if (res.rows.length > 0 && res.rows[0].record) {
|
||||
const ret = res.rows[0].record;
|
||||
if (ret.data) {
|
||||
try {
|
||||
ret.data = JSON.parse(ret.data);
|
||||
} catch(e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function getSignatures(client, userId, id) {
|
||||
const sql = `
|
||||
SELECT
|
||||
JSON_AGG(
|
||||
JSON_BUILD_OBJECT(
|
||||
'version', s.version,
|
||||
'id', s.signer_id,
|
||||
'signedOn', s.signed_on,
|
||||
'type', s.type,
|
||||
'jws', s.jws
|
||||
)
|
||||
) AS records
|
||||
FROM signature s
|
||||
INNER JOIN event e ON s.event_id = e.id
|
||||
WHERE e.event_id_uuid = $1
|
||||
AND e.user_id = $2;
|
||||
`
|
||||
const res = await client.query(sql, [
|
||||
id,
|
||||
userId,
|
||||
]);
|
||||
if (res.rows.length > 0 && res.rows[0].records) {
|
||||
return res.rows[0].records;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function get(input, userId) {
|
||||
let response = {
|
||||
success: false,
|
||||
error: 'Unknown error',
|
||||
};
|
||||
const client = await getPool();
|
||||
try {
|
||||
const existingEvent = await getEvent(client, userId, input.eventId, true, input.meta)
|
||||
if (!existingEvent) {
|
||||
response.error = 'event not found'
|
||||
} else {
|
||||
const data = {
|
||||
event: existingEvent
|
||||
}
|
||||
if (input.includeSignatures) {
|
||||
data.signatures = await getSignatures(client, userId, input.eventId)
|
||||
}
|
||||
response = {
|
||||
message: `retrieved: ${existingEvent.eventId}`,
|
||||
data,
|
||||
}
|
||||
}
|
||||
} catch(e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
await client.release();
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
async function save(client, userId, event, meta) {
|
||||
await client.query(`BEGIN`);
|
||||
const res = await client.query(`
|
||||
INSERT INTO event (
|
||||
user_id,
|
||||
version,
|
||||
event_id_uuid,
|
||||
event_type_id,
|
||||
agreement_id,
|
||||
sender_id,
|
||||
recipient_id,
|
||||
created,
|
||||
created_as_ts
|
||||
) VALUES (
|
||||
$1,
|
||||
$2,
|
||||
$3,
|
||||
(
|
||||
SELECT id
|
||||
FROM event_type
|
||||
WHERE value = $4
|
||||
),
|
||||
(
|
||||
SELECT id
|
||||
FROM agreement
|
||||
WHERE agreement_id_uuid = $5
|
||||
),
|
||||
$6,
|
||||
$7,
|
||||
$8,
|
||||
$9
|
||||
) RETURNING id;
|
||||
`, [
|
||||
userId,
|
||||
event.version,
|
||||
event.eventId,
|
||||
event.type,
|
||||
event.agreementId,
|
||||
event.senderId,
|
||||
event.recipientId,
|
||||
event.created,
|
||||
new Date(event.created).toISOString(),
|
||||
]);
|
||||
await client.query(`
|
||||
INSERT INTO event_data (
|
||||
user_id,
|
||||
event_id,
|
||||
data
|
||||
) VALUES (
|
||||
$1,
|
||||
$2,
|
||||
$3
|
||||
);
|
||||
`, [
|
||||
userId,
|
||||
res.rows[0].id,
|
||||
event.data,
|
||||
]);
|
||||
if (meta) {
|
||||
for await (const [key, value] of Object.entries(meta)) {
|
||||
await client.query(`
|
||||
INSERT INTO event_meta (
|
||||
user_id,
|
||||
event_id,
|
||||
key,
|
||||
value
|
||||
) VALUES (
|
||||
$1,
|
||||
$2,
|
||||
$3,
|
||||
$4
|
||||
);
|
||||
`, [
|
||||
userId,
|
||||
res.rows[0].id,
|
||||
key,
|
||||
value,
|
||||
]);
|
||||
}
|
||||
}
|
||||
await client.query('COMMIT');
|
||||
}
|
||||
|
||||
async function saveSignatures(client, userId, eventId, signatures) {
|
||||
await client.query(`BEGIN`);
|
||||
for (const signature of signatures) {
|
||||
await client.query(`
|
||||
INSERT INTO signature (
|
||||
user_id,
|
||||
version,
|
||||
signer_id,
|
||||
signed_on,
|
||||
type,
|
||||
jws,
|
||||
role_id,
|
||||
agreement_id,
|
||||
event_id,
|
||||
signed_on_as_ts
|
||||
) VALUES (
|
||||
$1,
|
||||
$2,
|
||||
$3,
|
||||
$4,
|
||||
$5,
|
||||
$6,
|
||||
(
|
||||
SELECT id
|
||||
FROM role
|
||||
WHERE value = $7
|
||||
AND user_id = $1
|
||||
),
|
||||
$8,
|
||||
(
|
||||
SELECT id
|
||||
FROM event
|
||||
WHERE event_id_uuid = $9
|
||||
AND user_id = $1
|
||||
),
|
||||
$10
|
||||
) ON CONFLICT DO NOTHING;
|
||||
`, [
|
||||
userId,
|
||||
signature.version,
|
||||
signature.id,
|
||||
signature.signedOn,
|
||||
signature.type,
|
||||
signature.jws,
|
||||
signature.role,
|
||||
null,
|
||||
eventId,
|
||||
new Date(signature.signedOn).toISOString(),
|
||||
]);
|
||||
}
|
||||
await client.query('COMMIT');
|
||||
}
|
||||
|
||||
async function create(input, userId, _client, _sender) {
|
||||
let response = {
|
||||
success: false,
|
||||
error: 'Unknown error',
|
||||
};
|
||||
const client = _client || await getPool();
|
||||
try {
|
||||
input.senderId = _sender ? _sender.didDoc.id : (await entity.getEntity(client, userId, input.senderShortName)).didDoc.id
|
||||
input.recipientId = (await entity.getEntity(client, userId, input.recipientShortName)).didDoc.id
|
||||
delete input.senderShortName
|
||||
delete input.recipientShortName
|
||||
const event = await JlincEvent.create(input);
|
||||
await save(client, userId, event, input.meta);
|
||||
// Pull down any referenced agreement content now (request refs are URIs,
|
||||
// permission refs are { reference, permitted }), so a get never has to.
|
||||
for (const ref of (event.references || [])) {
|
||||
const uri = typeof ref === "string" ? ref : ref?.reference;
|
||||
await cacheAgreementMarkdown({ userId, agreementUuid: event.agreementId, uri }, client);
|
||||
}
|
||||
response = {
|
||||
message: `created and saved: ${event.eventId}`,
|
||||
data: event,
|
||||
}
|
||||
} catch(e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
if (!_client)
|
||||
await client.release();
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
async function process(input, userId, _client, _event, _sender, meta) {
|
||||
let response = {
|
||||
success: false,
|
||||
error: 'Unknown error',
|
||||
};
|
||||
const client = _client || await getPool();
|
||||
try {
|
||||
const existingEvent = _event || await getEvent(client, userId, input.eventId, true)
|
||||
if (!existingEvent) {
|
||||
throw new Error('event does not exist')
|
||||
}
|
||||
const inputEntity = _sender || input.shortName ? await entity.getEntity(client, userId, input.shortName) : null;
|
||||
const didDoc = inputEntity ? inputEntity.didDoc : input.didDoc;
|
||||
const signingKey = inputEntity ? inputEntity.controlPrivateKeyB64U : input.signingKey;
|
||||
const signingPublicKey = inputEntity ? inputEntity.didDoc.verificationMethod[0].key : input.signingPublicKey;
|
||||
const signingInput = {
|
||||
event: existingEvent,
|
||||
didDoc,
|
||||
signingKey,
|
||||
signingPublicKey,
|
||||
}
|
||||
const eventData = await JlincEvent.sign(signingInput);
|
||||
await saveSignatures(client, userId, existingEvent.eventId, eventData.signatures);
|
||||
const audit = await JlincAudit.create(eventData);
|
||||
const auditInput = {
|
||||
audit,
|
||||
didDoc,
|
||||
signingKey,
|
||||
signingPublicKey,
|
||||
}
|
||||
const auditData = await JlincAudit.sign(auditInput);
|
||||
response = {
|
||||
message: `signed and saved: ${eventData?.event?.eventId}`,
|
||||
data: {
|
||||
auditData,
|
||||
},
|
||||
}
|
||||
if (input.archive) {
|
||||
if (meta) {
|
||||
response.data.auditData.meta = meta;
|
||||
}
|
||||
await putQueue(
|
||||
client,
|
||||
'audit',
|
||||
`${input.archive.url}/api/v1/audit/put`,
|
||||
{
|
||||
'Authorization': `Bearer ${input.archive.key}`,
|
||||
},
|
||||
response.data.auditData,
|
||||
)
|
||||
}
|
||||
} catch(e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
if (!_client)
|
||||
await client.release();
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
async function produce(input, userId) {
|
||||
let response = {
|
||||
success: false,
|
||||
error: 'Unknown error',
|
||||
};
|
||||
const client = await getPool();
|
||||
try {
|
||||
const shortName = input.senderShortName;
|
||||
const sender = (await entity.getEntity(client, userId, input.senderShortName))
|
||||
const created = (await create(input, userId, client, sender)).data;
|
||||
const processed = (await process(
|
||||
{
|
||||
eventId: created.eventId,
|
||||
shortName,
|
||||
archive: input.archive,
|
||||
},
|
||||
userId,
|
||||
client,
|
||||
created,
|
||||
sender,
|
||||
input.meta,
|
||||
)).data;
|
||||
response = {
|
||||
message: `created and processed: ${created.eventId}`,
|
||||
data: {
|
||||
created,
|
||||
processed,
|
||||
},
|
||||
}
|
||||
} catch(e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
await client.release();
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
export const event = {
|
||||
getEvent,
|
||||
getSignatures,
|
||||
get,
|
||||
create,
|
||||
process,
|
||||
produce,
|
||||
}
|
||||
12
backend/modules/core/data/index.js
Normal file
12
backend/modules/core/data/index.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import { agreement } from "./agreement.js";
|
||||
import { event } from "./event.js";
|
||||
import { entity } from "./entity.js";
|
||||
import { audit } from "./audit.js";
|
||||
|
||||
|
||||
export const data = {
|
||||
agreement,
|
||||
event,
|
||||
entity,
|
||||
audit,
|
||||
}
|
||||
55
backend/modules/core/did.js
Normal file
55
backend/modules/core/did.js
Normal file
@@ -0,0 +1,55 @@
|
||||
import pkg from '@jlinc/core';
|
||||
const { JlincDid } = pkg;
|
||||
|
||||
async function create(input) {
|
||||
const data = await JlincDid.create(input);
|
||||
const message = data?.didDoc?.id;
|
||||
return {
|
||||
data,
|
||||
message,
|
||||
}
|
||||
}
|
||||
|
||||
async function createKeys() {
|
||||
const data = await JlincDid.createKeys();
|
||||
const message = data?.didDoc?.id;
|
||||
return {
|
||||
data,
|
||||
message,
|
||||
}
|
||||
}
|
||||
|
||||
async function rotate(input) {
|
||||
const data = await JlincDid.rotate(input);
|
||||
const message = data?.didDoc?.id;
|
||||
return {
|
||||
data,
|
||||
message,
|
||||
}
|
||||
}
|
||||
|
||||
async function send(input) {
|
||||
const data = await JlincDid.send(input);
|
||||
const message = data?.didDoc?.id;
|
||||
return {
|
||||
data,
|
||||
message,
|
||||
}
|
||||
}
|
||||
|
||||
async function resolve(input) {
|
||||
const data = await JlincDid.resolve(input);
|
||||
const message = data?.didDoc?.id;
|
||||
return {
|
||||
data,
|
||||
message,
|
||||
}
|
||||
}
|
||||
|
||||
export const did = {
|
||||
create,
|
||||
createKeys,
|
||||
rotate,
|
||||
send,
|
||||
resolve,
|
||||
}
|
||||
35
backend/modules/core/event.js
Normal file
35
backend/modules/core/event.js
Normal file
@@ -0,0 +1,35 @@
|
||||
import pkg from '@jlinc/core';
|
||||
const { JlincEvent } = pkg;
|
||||
|
||||
async function create(input) {
|
||||
const data = await JlincEvent.create(input);
|
||||
const message = data?.eventId;
|
||||
return {
|
||||
data,
|
||||
message,
|
||||
}
|
||||
}
|
||||
|
||||
async function sign(input) {
|
||||
const data = await JlincEvent.sign(input);
|
||||
const message = data?.event?.eventId;
|
||||
return {
|
||||
data,
|
||||
message,
|
||||
}
|
||||
}
|
||||
|
||||
async function send(input) {
|
||||
const data = await JlincEvent.send(input);
|
||||
const message = data?.event?.eventId;
|
||||
return {
|
||||
data,
|
||||
message,
|
||||
}
|
||||
}
|
||||
|
||||
export const event = {
|
||||
create,
|
||||
sign,
|
||||
send,
|
||||
}
|
||||
235
backend/modules/core/index.js
Normal file
235
backend/modules/core/index.js
Normal file
@@ -0,0 +1,235 @@
|
||||
import { did } from "./did.js";
|
||||
import { agreement } from "./agreement.js";
|
||||
import { event } from "./event.js";
|
||||
import { audit } from "./audit.js";
|
||||
import { data } from "./data/index.js";
|
||||
import { archive } from "./archive.js";
|
||||
import { trackUsage } from "./usage.js";
|
||||
import { getConfig } from "../../common/config.js";
|
||||
import { dashboard } from "../dashboard/index.js";
|
||||
import { evaluate } from "../pep/index.js";
|
||||
|
||||
// The event/agreement UUIDs a request touched, so a usage row can link back to
|
||||
// them. Checks the known request/response shapes in priority order; non-core
|
||||
// calls (DID ops, etc.) have neither and fall through to null.
|
||||
function pickUsageIds(input, response) {
|
||||
const eventId =
|
||||
input?.eventId ??
|
||||
response?.eventId ??
|
||||
response?.event?.eventId ??
|
||||
response?.created?.eventId ??
|
||||
null;
|
||||
const agreementId =
|
||||
input?.agreementId ??
|
||||
response?.agreementId ??
|
||||
response?.agreement?.agreementId ??
|
||||
response?.event?.agreementId ??
|
||||
response?.created?.agreementId ??
|
||||
null;
|
||||
return { eventId, agreementId };
|
||||
}
|
||||
|
||||
async function post(req, res) {
|
||||
let response = {
|
||||
success: false,
|
||||
error: 'Unknown error',
|
||||
};
|
||||
let errorCode = 400;
|
||||
let type;
|
||||
const prefix = `${req.method} ${req.url}`;
|
||||
let evaluation = false;
|
||||
try {
|
||||
const input = req.body;
|
||||
if (input.auth) {
|
||||
evaluation = await evaluate(input.auth);
|
||||
} else {
|
||||
evaluation = true;
|
||||
}
|
||||
const config = getConfig();
|
||||
if (Object.keys(config.appModules).includes('core')) {
|
||||
switch (req.url) {
|
||||
case '/api/v1/auth':
|
||||
evaluation = await evaluate(input);
|
||||
if (evaluation)
|
||||
response = {
|
||||
data: {
|
||||
decision: true
|
||||
},
|
||||
message: 'Authorization allowed'
|
||||
};
|
||||
type = 'core';
|
||||
break;
|
||||
case '/api/v1/did/create':
|
||||
if (evaluation) response = await did.create(input);
|
||||
type = 'core';
|
||||
break;
|
||||
case '/api/v1/did/rotate':
|
||||
if (evaluation) response = await did.rotate(input);
|
||||
type = 'core';
|
||||
break;
|
||||
case '/api/v1/did/updateServices':
|
||||
if (evaluation) response = await did.updateServices(input);
|
||||
type = 'core';
|
||||
break;
|
||||
case '/api/v1/did/send':
|
||||
if (evaluation) response = await did.send(input);
|
||||
type = 'core';
|
||||
break;
|
||||
case '/api/v1/did/resolve':
|
||||
if (evaluation) response = await did.resolve(input);
|
||||
type = 'core';
|
||||
break;
|
||||
case '/api/v1/agreement/create':
|
||||
if (evaluation) response = await agreement.create(input);
|
||||
type = 'core';
|
||||
break;
|
||||
case '/api/v1/agreement/sign':
|
||||
if (evaluation) response = await agreement.sign(input);
|
||||
type = 'core';
|
||||
break;
|
||||
case '/api/v1/agreement/send':
|
||||
if (evaluation) response = await agreement.send(input);
|
||||
type = 'core';
|
||||
break;
|
||||
case '/api/v1/event/create':
|
||||
if (evaluation) response = await event.create(input);
|
||||
type = 'core';
|
||||
break;
|
||||
case '/api/v1/event/sign':
|
||||
if (evaluation) response = await event.sign(input);
|
||||
type = 'core';
|
||||
break;
|
||||
case '/api/v1/event/send':
|
||||
if (evaluation) response = await event.send(input);
|
||||
type = 'core';
|
||||
break;
|
||||
case '/api/v1/audit/create':
|
||||
if (evaluation) response = await audit.create(input);
|
||||
type = 'core';
|
||||
break;
|
||||
case '/api/v1/audit/sign':
|
||||
if (evaluation) response = await audit.sign(input);
|
||||
type = 'core';
|
||||
break;
|
||||
case '/api/v1/audit/send':
|
||||
if (evaluation) response = await audit.send(input);
|
||||
type = 'core';
|
||||
break;
|
||||
case '/api/v1/data/entity/get':
|
||||
if (evaluation) response = await data.entity.get(input, req.session.user_id);
|
||||
type = 'core';
|
||||
break;
|
||||
case '/api/v1/data/entity/domains/get':
|
||||
if (evaluation) response = await data.entity.getDomains(input, req.session.user_id);
|
||||
type = 'core';
|
||||
break;
|
||||
case '/api/v1/data/entity/create':
|
||||
if (evaluation) response = await data.entity.create(input, req.session.user_id);
|
||||
type = 'core';
|
||||
break;
|
||||
case '/api/v1/data/agreement/get':
|
||||
if (evaluation) response = await data.agreement.get(input, req.session.user_id);
|
||||
type = 'core';
|
||||
break;
|
||||
case '/api/v1/data/agreement/create':
|
||||
if (evaluation) response = await data.agreement.create(input, req.session.user_id);
|
||||
type = 'core';
|
||||
break;
|
||||
case '/api/v1/data/agreement/process':
|
||||
if (evaluation) response = await data.agreement.process(input, req.session.user_id);
|
||||
type = 'core';
|
||||
break;
|
||||
case '/api/v1/data/agreement/produce':
|
||||
if (evaluation) response = await data.agreement.produce(input, req.session.user_id);
|
||||
type = 'core';
|
||||
break;
|
||||
case '/api/v1/data/event/get':
|
||||
if (evaluation) response = await data.event.get(input, req.session.user_id);
|
||||
type = 'core';
|
||||
break;
|
||||
case '/api/v1/data/event/create':
|
||||
if (evaluation) response = await data.event.create(input, req.session.user_id);
|
||||
type = 'core';
|
||||
break;
|
||||
case '/api/v1/data/event/process':
|
||||
if (evaluation) response = await data.event.process(input, req.session.user_id);
|
||||
type = 'core';
|
||||
break;
|
||||
case '/api/v1/data/event/produce':
|
||||
if (evaluation) response = await data.event.produce(input, req.session.user_id);
|
||||
type = 'core';
|
||||
break;
|
||||
case '/api/v1/data/audit/verify':
|
||||
if (evaluation) response = await data.audit.verify(input, req.session.user_id);
|
||||
type = 'core';
|
||||
break;
|
||||
}
|
||||
if (Object.keys(config.appModules).includes('archive')) {
|
||||
switch (req.url) {
|
||||
case '/api/v1/audit/put':
|
||||
if (evaluation) response = await archive.put(input);
|
||||
type = 'archive';
|
||||
break;
|
||||
case '/api/v1/audit/get':
|
||||
if (evaluation) response = await archive.get(input);
|
||||
type = 'archive';
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Dashboard is its own app module: read-only console data, dispatched
|
||||
// separately from core and typed 'dashboard' so it never counts as usage.
|
||||
if (evaluation && !type && Object.keys(config.appModules).includes('dashboard')) {
|
||||
const dashboardResponse = await dashboard.dispatch(req.url, input, req.session.user_id);
|
||||
if (dashboardResponse) {
|
||||
response = dashboardResponse;
|
||||
type = 'dashboard';
|
||||
}
|
||||
}
|
||||
if (evaluation && !type) {
|
||||
response.error = 'Page not found';
|
||||
errorCode = 404;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
response.error = e.message;
|
||||
} finally {
|
||||
if (evaluation) {
|
||||
if (response?.message) {
|
||||
req.apiMessage = response.message;
|
||||
} else if (response?.data?.error) {
|
||||
req.apiMessage = `ERROR: ${response.data.error}`;
|
||||
} else {
|
||||
req.apiMessage = `ERROR: unknown error`;
|
||||
}
|
||||
if (response?.data)
|
||||
response = response.data;
|
||||
res.status(response?.error ? errorCode : 200).json(response);
|
||||
} else {
|
||||
response = {
|
||||
data: {
|
||||
decision: false
|
||||
},
|
||||
message: 'Authorization denied'
|
||||
}
|
||||
req.apiMessage = response.message;
|
||||
res.status(200).json(response.data);
|
||||
}
|
||||
// Dashboard calls are console reads, not billable API usage — don't track them.
|
||||
if (type !== 'dashboard') {
|
||||
const { eventId, agreementId } = pickUsageIds(req.body, response);
|
||||
await trackUsage(req.session.user_id, req.url, type, response?.error ? false : true, eventId, agreementId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const core = {
|
||||
did,
|
||||
agreement,
|
||||
event,
|
||||
audit,
|
||||
archive,
|
||||
post,
|
||||
};
|
||||
|
||||
|
||||
86
backend/modules/core/usage.js
Normal file
86
backend/modules/core/usage.js
Normal file
@@ -0,0 +1,86 @@
|
||||
import { getPool } from "../../db/index.js";
|
||||
|
||||
export async function trackUsage(user_id, url, type, success, eventId = null, agreementId = null) {
|
||||
const client = await getPool();
|
||||
try {
|
||||
await client.query(`
|
||||
INSERT INTO usage_url (
|
||||
url,
|
||||
module
|
||||
) VALUES (
|
||||
$1,
|
||||
$2
|
||||
) ON CONFLICT DO NOTHING;
|
||||
`, [
|
||||
url,
|
||||
type,
|
||||
]);
|
||||
await client.query(`
|
||||
INSERT INTO usage (
|
||||
user_id,
|
||||
usage_url_id,
|
||||
success,
|
||||
event_id_uuid,
|
||||
agreement_id_uuid
|
||||
) VALUES (
|
||||
$1,
|
||||
(
|
||||
SELECT id
|
||||
FROM usage_url
|
||||
WHERE url = $2
|
||||
),
|
||||
$3,
|
||||
$4,
|
||||
$5
|
||||
);
|
||||
`, [
|
||||
user_id,
|
||||
url,
|
||||
success,
|
||||
eventId,
|
||||
agreementId,
|
||||
]);
|
||||
} catch(e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
await client.release();
|
||||
}
|
||||
}
|
||||
|
||||
export async function getUsage(user, begin, end) {
|
||||
const client = await getPool();
|
||||
let ret = [];
|
||||
try {
|
||||
let res = await client.query(`
|
||||
WITH base AS (
|
||||
SELECT
|
||||
uu.module,
|
||||
COUNT(*) AS num
|
||||
FROM usage u
|
||||
JOIN usage_url uu ON uu.id = u.usage_url_id
|
||||
WHERE u.created_ts >= $1
|
||||
AND u.created_ts < $2
|
||||
AND u.user_id = $3
|
||||
GROUP BY uu.module
|
||||
)
|
||||
SELECT
|
||||
json_object_agg(
|
||||
module,
|
||||
num
|
||||
) AS usage_counts
|
||||
FROM base
|
||||
`, [
|
||||
begin,
|
||||
end,
|
||||
user.id,
|
||||
]);
|
||||
if (res.rows.length > 0) {
|
||||
ret = res.rows[0].usage_counts;
|
||||
}
|
||||
} catch(e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
await client.release();
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
12
backend/modules/dashboard/account.js
Normal file
12
backend/modules/dashboard/account.js
Normal 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();
|
||||
}
|
||||
}
|
||||
50
backend/modules/dashboard/api.js
Normal file
50
backend/modules/dashboard/api.js
Normal 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();
|
||||
60
backend/modules/dashboard/api.test.js
Normal file
60
backend/modules/dashboard/api.test.js
Normal 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" } });
|
||||
});
|
||||
});
|
||||
33
backend/modules/dashboard/cache.js
Normal file
33
backend/modules/dashboard/cache.js
Normal 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;
|
||||
}
|
||||
46
backend/modules/dashboard/cache.test.js
Normal file
46
backend/modules/dashboard/cache.test.js
Normal 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
|
||||
});
|
||||
});
|
||||
17
backend/modules/dashboard/index.js
Normal file
17
backend/modules/dashboard/index.js
Normal 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 };
|
||||
79
backend/modules/dashboard/query.js
Normal file
79
backend/modules/dashboard/query.js
Normal 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 };
|
||||
}
|
||||
107
backend/modules/dashboard/query.test.js
Normal file
107
backend/modules/dashboard/query.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
61
backend/modules/dashboard/series.js
Normal file
61
backend/modules/dashboard/series.js
Normal 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();
|
||||
}
|
||||
}
|
||||
37
backend/modules/dashboard/series.test.js
Normal file
37
backend/modules/dashboard/series.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
55
backend/modules/dashboard/service.js
Normal file
55
backend/modules/dashboard/service.js
Normal 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 };
|
||||
39
backend/modules/dashboard/storage.js
Normal file
39
backend/modules/dashboard/storage.js
Normal 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();
|
||||
}
|
||||
}
|
||||
30
backend/modules/dashboard/storage.test.js
Normal file
30
backend/modules/dashboard/storage.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
248
backend/modules/dashboard/transactions.js
Normal file
248
backend/modules/dashboard/transactions.js
Normal 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();
|
||||
}
|
||||
}
|
||||
63
backend/modules/dashboard/transactions.test.js
Normal file
63
backend/modules/dashboard/transactions.test.js
Normal 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/);
|
||||
});
|
||||
});
|
||||
25
backend/modules/dashboard/usage.js
Normal file
25
backend/modules/dashboard/usage.js
Normal 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();
|
||||
}
|
||||
}
|
||||
63
backend/modules/dashboard/validate.js
Normal file
63
backend/modules/dashboard/validate.js
Normal 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),
|
||||
},
|
||||
};
|
||||
}
|
||||
101
backend/modules/dashboard/validate.test.js
Normal file
101
backend/modules/dashboard/validate.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
146
backend/modules/dashboard/verify.js
Normal file
146
backend/modules/dashboard/verify.js
Normal 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;
|
||||
}
|
||||
225
backend/modules/dashboard/verify.test.js
Normal file
225
backend/modules/dashboard/verify.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
63
backend/modules/pep/cerbos.js
Normal file
63
backend/modules/pep/cerbos.js
Normal file
@@ -0,0 +1,63 @@
|
||||
import { getConfig } from "../../common/config.js";
|
||||
import axios from 'axios';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
export async function init() {
|
||||
const config = getConfig();
|
||||
// No initialization required
|
||||
}
|
||||
|
||||
export async function check(req) {
|
||||
// Ref:
|
||||
// ----
|
||||
// curl -X POST http://localhost:3592/api/check/resources \
|
||||
// -H "Content-Type: application/json" \
|
||||
// -d '{
|
||||
// "requestId": "test-check-1",
|
||||
// "principal": {
|
||||
// "id": "user123",
|
||||
// "roles": ["user"]
|
||||
// },
|
||||
// "resources": [
|
||||
// {
|
||||
// "resource": {
|
||||
// "kind": "privateData",
|
||||
// "id": "record001",
|
||||
// "attr": {}
|
||||
// },
|
||||
// "actions": ["read"]
|
||||
// }
|
||||
// ]
|
||||
// }'
|
||||
|
||||
const r = {
|
||||
requestId: uuidv4(),
|
||||
principal: {
|
||||
id: req.subject.id,
|
||||
roles: [req.subject.type]
|
||||
},
|
||||
resources: [
|
||||
{
|
||||
resource: {
|
||||
kind: req.resource.type,
|
||||
id: req.resource.id,
|
||||
attr: {}
|
||||
},
|
||||
actions: [req.action.name]
|
||||
}
|
||||
]
|
||||
}
|
||||
if (req.resource.properties.ownerID) {
|
||||
r.resources[0].resource.attr = { owner: req.resource.properties.ownerID }
|
||||
}
|
||||
const config = getConfig();
|
||||
const result = await axios.post(
|
||||
`${config.pdpUrl}/api/check/resources`,
|
||||
r,
|
||||
)
|
||||
console.log(`Auth check: ${JSON.stringify(result.data)}`);
|
||||
if (result.data?.results[0]?.actions[req.action.name] === 'EFFECT_ALLOW') {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
35
backend/modules/pep/index.js
Normal file
35
backend/modules/pep/index.js
Normal file
@@ -0,0 +1,35 @@
|
||||
import { getConfig } from "../../common/config.js";
|
||||
|
||||
let evaluator;
|
||||
|
||||
export async function loadPep(app) {
|
||||
const config = getConfig();
|
||||
if (config.pdpType) {
|
||||
const pepModulePath = `./${config.pdpType}.js`;
|
||||
const { init, check } = await import(pepModulePath);
|
||||
await init();
|
||||
|
||||
// // Follow AuthZEN base format
|
||||
// // {
|
||||
// // subject: {
|
||||
// // type: "user",
|
||||
// // id: "1abc",
|
||||
// // },
|
||||
// // action: {
|
||||
// // name: "read"
|
||||
// // },
|
||||
// // resource: {
|
||||
// // type: "data",
|
||||
// // id: "2def",
|
||||
// // properties: {
|
||||
// // ownerID: "my@me.com"
|
||||
// // }
|
||||
// // }
|
||||
// // }
|
||||
evaluator = check;
|
||||
}
|
||||
}
|
||||
|
||||
export async function evaluate(req) {
|
||||
return await evaluator(req);
|
||||
}
|
||||
Reference in New Issue
Block a user