addition of new dashboard

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

View File

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

View 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;
}
});
});

View 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();
}
}

View 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
});
});

View 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,
}

View 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,
}

View 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,
}

View 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,
}

View 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,
}

View 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,
}

View 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,
}

View 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,
}

View 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,
}

View 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,
};

View 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;
}