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

6
backend/.prettierrc Normal file
View File

@@ -0,0 +1,6 @@
{
"tabWidth": 4,
"useTabs": false,
"semi": true,
"printWidth": 180
}

24
backend/apps.js Normal file
View File

@@ -0,0 +1,24 @@
import { getConfig } from "./common/config.js";
import { watchAudits } from "./common/queue.js";
import { getPool } from "./db/index.js";
export async function loadApps() {
const config = getConfig();
const client = await getPool();
for (const type in config.appModules) {
// Internal modules (e.g. dashboard) have no `app` row / API surface.
if (config.appModules[type]?.internal) continue;
await client.query(`
INSERT INTO public.app (
type
) VALUES (
$1
) ON CONFLICT DO NOTHING;
`, [
type,
]);
if (type === 'core')
watchAudits();
}
await client.release();
}

69
backend/common/config.js Normal file
View File

@@ -0,0 +1,69 @@
const DEFAULT_KEY_CACHE = { ttlMs: 30 * 60 * 1000, max: 10_000 };
const config = {
debug: false,
dashboard: { core: {}, audit: {}, keyCache: { ...DEFAULT_KEY_CACHE } },
};
export async function loadConfig() {
if (process.env.DEBUG) config.debug = process.env.DEBUG;
if (process.env.POSTGRES_URL) config.postgresUrl = process.env.POSTGRES_URL;
if (process.env.PUBLIC_CORE_URL) config.publicCoreUrl = process.env.PUBLIC_CORE_URL;
if (process.env.PUBLIC_ARCHIVE_URL) config.publicArchiveUrl = process.env.PUBLIC_ARCHIVE_URL;
if (process.env.PUBLIC_CALLBACK_URL) config.publicCallbackUrl = process.env.PUBLIC_CALLBACK_URL;
if (process.env.SECURE_SECRET) config.secureSecret = process.env.SECURE_SECRET;
if (process.env.DEFAULT_FEDID_URL) config.defaultFedidUrl = process.env.DEFAULT_FEDID_URL;
if (process.env.AUTH_MODULES) {
config.authModules = {};
const authModules = process.env.AUTH_MODULES.split(',').map(a => a.trim());
for (const type of authModules) {
const authModulePath = `../modules/auth/${type}.js`;
const { getModuleConfig } = await import(authModulePath);
config.authModules[type] = getModuleConfig();
}
}
config.appModules = {};
let appModules = [
'core',
'archive',
];
if (process.env.APP_MODULES) {
appModules = process.env.APP_MODULES.split(',').map(a => a.trim());
}
for (const type of appModules) {
const appModulePath = `../modules/app/${type}.js`;
const { getModuleConfig } = await import(appModulePath);
config.appModules[type] = getModuleConfig();
}
if (process.env.PDP_TYPE) config.pdpType = process.env.PDP_TYPE;
if (process.env.PDP_URL) config.pdpUrl = process.env.PDP_URL;
if (process.env.INSTANT_QUEUE) config.instantQueue = process.env.INSTANT_QUEUE;
// Remote agreement-markdown caching: exponential backoff for failed fetches
// so an invalid URL isn't retried on every referencing create. Defaults:
// 1 minute → 1 day, factor 2, give up after 10 attempts. All overridable.
config.agreementCache = {
startingDelayMs: Number(process.env.AGREEMENT_CACHE_STARTING_DELAY_MS) || 60_000,
maxDelayMs: Number(process.env.AGREEMENT_CACHE_MAX_DELAY_MS) || 86_400_000,
maxRetries: Number(process.env.AGREEMENT_CACHE_MAX_RETRIES) || 10,
factor: Number(process.env.AGREEMENT_CACHE_BACKOFF_FACTOR) || 2,
};
// Optional federation: source dashboard core/audit data from remote servers
// (via their /api/v1/data/dashboard/* API + an API key) instead of locally.
// Unset => serve from the local DB (the single-operator default).
const keyCacheTtl = Number(process.env.DASHBOARD_KEY_CACHE_TTL_MS);
const keyCacheMax = Number(process.env.DASHBOARD_KEY_CACHE_MAX);
config.dashboard = {
core: { url: process.env.DASHBOARD_CORE_URL || null, key: process.env.DASHBOARD_CORE_KEY || null },
audit: { url: process.env.DASHBOARD_AUDIT_URL || null, key: process.env.DASHBOARD_AUDIT_KEY || null },
keyCache: {
ttlMs: Number.isFinite(keyCacheTtl) ? keyCacheTtl : DEFAULT_KEY_CACHE.ttlMs,
max: Number.isFinite(keyCacheMax) && keyCacheMax > 0 ? keyCacheMax : DEFAULT_KEY_CACHE.max,
},
};
}
export function getConfig() {
return config;
}

14
backend/common/http.js Normal file
View File

@@ -0,0 +1,14 @@
export const fail = (res, status, code, message) =>
res.status(status).json({ error: { code, message } });
// Requires a logged-in user (401) and turns a throw into a 500 envelope, so a
// data-layer failure never masquerades as empty-but-200 data.
export const sessionRoute = (label, fn) => async (req, res) => {
if (!req.user) return fail(res, 401, "unauthorized", "Not logged in");
try {
await fn(req, res);
} catch (e) {
console.error(`${label} ${req.method} ${req.path}:`, e);
fail(res, 500, "internal", "Internal error");
}
};

197
backend/common/queue.js Normal file
View File

@@ -0,0 +1,197 @@
import { getConfig } from "../common/config.js";
import { getPool } from "../db/index.js";
import { sleep } from "./sleep.js";
import axios from 'axios';
const backOffUrls = {};
async function getQueue(client, type, batchSize) {
const now = new Date();
const sql = `
SELECT
q.id,
qu.id AS queue_url_id,
qu.value AS url,
q.headers,
q.data,
q.run_count
FROM queue q
INNER JOIN queue_type qt ON q.queue_type_id = qt.id
INNER JOIN queue_url qu ON q.queue_url_id = qu.id
WHERE qt.value = $1
AND qu.next_run_ts <= $2
ORDER BY qu.next_run_ts ASC, q.id ASC
LIMIT $3;
`;
const data = [
type,
now,
batchSize,
];
const res = await client.query(sql, data);
return res.rows;
}
export async function putQueue(client, type, url, headers, data) {
await client.query(`
INSERT INTO queue_url (
value
) VALUES (
$1
) ON CONFLICT DO NOTHING;
`, [
url,
]);
await client.query(`
INSERT INTO queue (
queue_type_id,
queue_url_id,
headers,
data
) VALUES (
(
SELECT id
FROM queue_type
WHERE value = $1
),
(
SELECT id
FROM queue_url
WHERE value = $2
),
$3,
$4
);
`, [
type,
url,
headers,
data
]);
const config = getConfig();
if (config.instantQueue) {
await processBatch(client, type);
}
}
async function updateQueue(client, item, lastFail) {
// Queue back off
// ==============
// - Start with 30 seconds
// - Double for every run
// - Max 120 minutes
const delay = 30;
const maxDelay = 120 * 60;
const delaySeconds = Math.min(delay * Math.pow(2, item.run_count - 1), maxDelay);
const now = new Date();
const nextRunTs = new Date(now.getTime() + delaySeconds * 1000);
if (backOffUrls[item.queue_url_id]) {
if (backOffUrls[item.queue_url_id] < nextRunTs) {
backOffUrls[item.queue_url_id] = nextRunTs;
} else {
nextRunTs = backOffUrls[item.queue_url_id];
}
} else {
backOffUrls[item.queue_url_id] = nextRunTs;
}
await client.query(`
UPDATE queue_url SET
next_run_ts = $1,
updated_ts = $2
WHERE id = $3
`, [
backOffUrls[item.queue_url_id].toISOString(),
now.toISOString(),
item.id,
]);
await client.query(`
UPDATE queue SET
run_count = $1,
last_fail = $2,
updated_ts = $3
WHERE id = $4
`, [
parseInt(item.run_count) + 1,
lastFail,
now.toISOString(),
item.id,
]);
}
async function deleteQueue(client, id) {
await client.query(`
DELETE FROM queue
WHERE id = $1
`, [
id,
]);
}
async function processBatch(client, type) {
const batchSize = 100;
const queueList = await getQueue(client, type, batchSize);
const now = new Date();
for await (const item of queueList) {
if (backOffUrls[item.queue_url_id] && backOffUrls[item.queue_url_id] > now)
continue;
try {
let entry = 'unknown';
if (item.data.audit) {
const uuid = item.data.audit.eventId ?? item.data.audit.agreementId;
const auditType = item.data.audit.eventId ? 'event' : 'agreement';
entry = `${auditType}:${uuid}`
}
console.log(`${type.toUpperCase()} - ${entry} (${item.run_count})`);
let result;
try {
result = await axios.post(
item.url,
item.data,
{
headers: item.headers,
}
)
} catch (e) {
result = e
}
if (result?.status === 200) {
await deleteQueue(client, item.id);
} else {
let lastFail = `${result.status}`;
if (result.response.data.error) {
lastFail += ` - ${result.response.data.error}`
}
await updateQueue(client, item, lastFail);
}
} catch (e) {
console.error(e);
}
}
return queueList.length;
}
async function processQueue(client, type) {
while (true) {
const count = await processBatch(client, type);
if (count === 0) {
break;
}
}
}
async function watchQueue(client, type) {
const repeat = 30 * 1000; // seconds
while (true) {
await processQueue(client, type);
await sleep(repeat);
}
}
export async function watchAudits() {
const client = await getPool();
const config = getConfig();
if (!config.instantQueue) {
await watchQueue(client, 'audit');
await client.release();
}
}

1
backend/common/sleep.js Normal file
View File

@@ -0,0 +1 @@
export const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

View File

@@ -0,0 +1,4 @@
{
"purposes": [],
"prohibitions": []
}

View File

@@ -0,0 +1,18 @@
JLINC General Audit Agreement
-----------------------------
This document outlines the terms and conditions for using the JLINC protocol for auditing purposes. The purpose of this agreement is to clarify that no formal legal agreements are established between parties beyond their use of the JLINC protocol for data communication auditing.
**Purpose:** This agreement exists solely to provide a framework for using the JLINC protocol to audit data communication among parties. It does not create any legal obligations or enforceable commitments beyond its scope.
**Awareness of Agreement:** Recognizing that some parties may not be aware of this agreement, it is understood that by utilizing the JLINC protocol under this agreement, they are not deemed to have accepted these terms.
**Use of Protocol:** Under this agreement, the JLINC protocol is employed exclusively for auditing data communication, ensuring transparency and efficiency in transactions without establishing any formal agreements beyond this document.
**No Data Provenance/Protection:** It is clarified that no data provenance or protection measures are provided under this agreement. Entities leveraging this agreement are responsible for their own data integrity and security outside the protocol's use.
**Liability:** Liability is limited to actions taken within the context of using the JLINC protocol. No party assumes responsibility for others' actions beyond their direct involvement with the JLINC protocol.
**Governing Law:** This agreement is governed by the laws of the system operator's and user's jurisdictions. Any legal matters arising from this agreement must be resolved in courts within those jurisdictions.
By adhering to these terms, parties acknowledge their responsibilities and agree to use the specified protocol solely for auditing purposes without implied legal obligations beyond those stated herein.

208
backend/db/index.js Normal file
View File

@@ -0,0 +1,208 @@
import fs from "fs";
import path from "path";
import { createHash } from "crypto";
import pkg from "pg";
const { Pool } = pkg;
import { sleep } from "../common/sleep.js";
import { getConfig } from "../common/config.js";
import { firstNonBlankLine } from "../util/firstNonBlankLine.js";
// NOTE: the domain `data` layer (which pulls in @jlinc/core) is imported lazily
// inside populateAgreements() only. Keeping it out of this module's top-level
// imports lets the pool/migrate code — and anything that just needs getPool() —
// load without dragging in the crypto engine.
let pool;
export async function init() {
const config = getConfig();
let ready = false;
let client;
while (!ready) {
try {
pool = new Pool({
connectionString: config.postgresUrl,
});
client = await pool.connect();
const res = await client.query(`SELECT 1`);
if (res.rows.length < 1) {
throw new Error("");
}
ready = true;
// eslint-disable-next-line no-unused-vars
} catch (e) {
console.log("DB not ready, waiting...");
await sleep(1000);
}
}
await client.release();
}
export async function getPool() {
return await pool.connect();
}
export async function close() {
console.log(`Closing DB`);
await pool.end();
}
export async function migrate() {
console.log(`Starting migration`);
const client = await pool.connect();
try {
const migrationExists = await client.query(`
SELECT
CASE
WHEN (SELECT COUNT(1) FROM information_schema.tables WHERE table_schema = 'system' AND table_name = 'migrate') > 0
THEN TRUE
ELSE FALSE
END AS exists
`);
if (!migrationExists.rows[0].exists) {
await client.query(`
DROP SCHEMA IF EXISTS system CASCADE;
CREATE SCHEMA system;
-- Migrations
DROP TABLE IF EXISTS system.migrate CASCADE;
CREATE TABLE system.migrate (
id TEXT NOT NULL,
status TEXT NOT NULL,
created_ts TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx__system__migrate__id ON system.migrate (id);
CREATE INDEX idx__system__migrate__status ON system.migrate (status);
CREATE INDEX idx__system__migrate__created_ts ON system.migrate (created_ts);
`);
}
let lastMigration = "0";
const lastMigrationResult = await client.query(`
SELECT id
FROM system.migrate
ORDER BY id DESC
LIMIT 1;
`);
if (lastMigrationResult.rows.length > 0 && lastMigrationResult.rows[0].id) {
lastMigration = lastMigrationResult.rows[0].id;
}
const files = fs.readdirSync("./db/migrations", {
withFileTypes: true,
});
for await (const file of files) {
const migrationId = file.name.slice(0, 6);
if (migrationId > lastMigration) {
const label = file.name.slice(7, file.name.length - 4);
console.log(`Running migration ${label} (${migrationId})`);
const sqlStr = fs.readFileSync(path.join("./db/migrations", file.name), "utf8");
try {
await client.query("BEGIN");
await client.query(sqlStr);
await client.query(
`
INSERT INTO system.migrate (
id,
status
) VALUES (
$1,
'complete'
);
`,
[migrationId],
);
await client.query("COMMIT");
} catch (e) {
console.error(e);
await client.query("ROLLBACK");
throw new Error("migration error");
}
}
}
} catch (e) {
console.error(e);
} finally {
await client.release();
}
console.log(`Ending migration`);
}
export async function populateAgreements() {
console.log(`Starting agreement population`);
const config = getConfig();
const { data } = await import("../modules/core/data/index.js");
const client = await pool.connect();
try {
const files = fs.readdirSync("./db/agreements", {
withFileTypes: true,
});
for await (const file of files) {
if (file.name.endsWith('.json'))
continue;
const agreementUuid = file.name.slice(0, 36);
const markdown = fs.readFileSync(path.join("./db/agreements", file.name), "utf8").trim();
const json = JSON.parse(fs.readFileSync(path.join("./db/agreements", file.name.replace('.md', '.json')), "utf8").trim());
const title = firstNonBlankLine(markdown);
const hash = createHash('sha256')
.update(markdown)
.digest('hex')
try {
const agreementExists = await client.query(`
SELECT
CASE
WHEN (SELECT COUNT(1) FROM agreement_content WHERE title = $1 AND hash = $2 AND user_id IS NULL) > 0
THEN TRUE
ELSE FALSE
END AS exists
`,
[
title,
hash,
]
);
if (!agreementExists.rows[0].exists) {
console.log(`Adding agreement '${title}' (${agreementUuid})`);
// Agreement content is immutable once inserted (it may already be
// signed), so this is INSERT-only — never an upsert.
await client.query(`
INSERT INTO agreement_content (
title,
markdown,
hash,
key,
agreement_uuid
) VALUES (
$1,
$2,
$3,
$4,
$5
);
`, [
title,
markdown,
hash,
json.key,
agreementUuid,
]);
const agreement = {
uri: `${config.publicCoreUrl}/agreements/${hash}`,
purposes: json.purposes || [],
prohibitions: json.prohibitions || [],
shortNames: [],
validRoles: json.validRoles || [],
}
await data.agreement.create(agreement, null, client, agreementUuid);
}
} catch (e) {
console.error(e);
throw new Error("agreement population error");
}
}
} catch (e) {
console.error(e);
} finally {
await client.release();
}
console.log(`Ending agreement population`);
}

View File

@@ -0,0 +1,45 @@
-- DROP TABLE IF EXISTS public.user CASCADE;
CREATE TABLE public.user (
id BIGSERIAL PRIMARY KEY,
username TEXT,
photo TEXT,
issuer TEXT NOT NULL,
type TEXT NOT NULL,
identifier TEXT NOT NULL,
created_ts TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_ts TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uniq__user__issuer_identifier UNIQUE (issuer, identifier)
);
CREATE INDEX idx__user__username ON public.user (username);
CREATE INDEX idx__user__identifier ON public.user (identifier);
CREATE INDEX idx__user__type ON public.user (type);
CREATE INDEX idx__user__issuer ON public.user (issuer);
CREATE INDEX idx__user__created_ts ON public.user (created_ts);
CREATE INDEX idx__user__updated_ts ON public.user (updated_ts);
-- DROP TABLE IF EXISTS public.app CASCADE;
CREATE TABLE public.app (
id BIGSERIAL PRIMARY KEY,
type TEXT UNIQUE NOT NULL,
created_ts TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_ts TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx__app__type ON public.app (type);
CREATE INDEX idx__app__created_ts ON public.app (created_ts);
CREATE INDEX idx__app__updated_ts ON public.app (updated_ts);
-- DROP TABLE IF EXISTS public.auth CASCADE;
CREATE TABLE public.auth (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES public.user(id),
app_id BIGINT NOT NULL REFERENCES public.app(id),
api_key TEXT NOT NULL,
created_ts TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_ts TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uniq__auth__user_id_app_id UNIQUE (user_id, app_id)
);
CREATE INDEX idx__auth__user_id ON public.auth (user_id);
CREATE INDEX idx__auth__app_id ON public.auth (app_id);
CREATE INDEX idx__auth__api_key ON public.auth (api_key);
CREATE INDEX idx__auth__created_ts ON public.auth (created_ts);
CREATE INDEX idx__auth__updated_ts ON public.auth (updated_ts);

View File

@@ -0,0 +1,42 @@
CREATE TYPE HASH_TYPE AS ENUM ('SHA256');
CREATE TYPE SIGNATURE_TYPE AS ENUM ('JWS/JCS');
-- DROP TABLE IF EXISTS public.audit CASCADE;
CREATE TABLE public.audit (
audit_id BIGSERIAL PRIMARY KEY,
version SMALLINT NOT NULL,
event_id UUID,
agreement_id UUID,
hash_type HASH_TYPE NOT NULL,
digest TEXT NOT NULL,
created BIGINT NOT NULL,
created_ts TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_ts TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx__public__audit__event_id ON public.audit (event_id);
CREATE INDEX idx__public__audit__agreement_id ON public.audit (agreement_id);
CREATE INDEX idx__public__audit__created ON public.audit (created);
CREATE INDEX idx__public__audit__created_ts ON public.audit (created_ts);
CREATE INDEX idx__public__audit__updated_ts ON public.audit (updated_ts);
ALTER TABLE public.audit
ADD CONSTRAINT cnst__audit__event_or_agreement
CHECK (num_nonnulls(event_id, agreement_id) = 1);
-- DROP TABLE IF EXISTS public.audit_signature CASCADE;
CREATE TABLE public.audit_signature (
signature_id BIGSERIAL PRIMARY KEY,
audit_id BIGINT NOT NULL REFERENCES public.audit(audit_id),
version SMALLINT NOT NULL,
id TEXT NOT NULL,
signedOn BIGINT NOT NULL,
type SIGNATURE_TYPE NOT NULL,
jws TEXT NOT NULL,
created_ts TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_ts TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx__public__audit_signature__id ON public.audit_signature (id);
CREATE INDEX idx__public__audit_signature__signedOn ON public.audit_signature (signedOn);
CREATE INDEX idx__public__audit_signature__type ON public.audit_signature (type);
CREATE INDEX idx__public__audit_signature__created_ts ON public.audit_signature (created_ts);
CREATE INDEX idx__public__audit_signature__updated_ts ON public.audit_signature (updated_ts);

View File

@@ -0,0 +1,28 @@
-- DROP TABLE IF EXISTS public.usage_url CASCADE;
CREATE TABLE public.usage_url (
id BIGSERIAL PRIMARY KEY,
url TEXT NOT NULL,
module TEXT NOT NULL,
created_ts TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_ts TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uniq__usage_url__url_module UNIQUE (url, module)
);
CREATE INDEX idx__public__usage_url__url ON public.usage_url (url);
CREATE INDEX idx__public__usage_url__module ON public.usage_url (module);
CREATE INDEX idx__public__usage_url__created_ts ON public.usage_url (created_ts);
CREATE INDEX idx__public__usage_url__updated_ts ON public.usage_url (updated_ts);
-- DROP TABLE IF EXISTS public.usage CASCADE;
CREATE TABLE public.usage (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES public.user(id),
usage_url_id BIGINT NOT NULL REFERENCES public.usage_url(id),
success BOOLEAN NOT NULL DEFAULT TRUE,
created_ts TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_ts TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx__public__usage__user_id ON public.usage (user_id);
CREATE INDEX idx__public__usage__usage_url_id ON public.usage (usage_url_id);
CREATE INDEX idx__public__usage__success ON public.usage (success);
CREATE INDEX idx__public__usage__created_ts ON public.usage (created_ts);
CREATE INDEX idx__public__usage__updated_ts ON public.usage (updated_ts);

View File

@@ -0,0 +1,220 @@
-- Agreements
-- DROP TABLE IF EXISTS public.agreement CASCADE;
CREATE TABLE public.agreement (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT REFERENCES public.user(id),
version SMALLINT NOT NULL,
parent TEXT NOT NULL,
agreement_id_uuid UUID UNIQUE NOT NULL,
created BIGINT NOT NULL,
created_as_ts TIMESTAMPTZ NOT NULL,
created_ts TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_ts TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx__public__agreement__user_id ON public.agreement (user_id);
CREATE INDEX idx__public__agreement__version ON public.agreement (version);
CREATE INDEX idx__public__agreement__parent ON public.agreement (parent);
CREATE INDEX idx__public__agreement__agreement_id_uuid ON public.agreement (agreement_id_uuid);
CREATE INDEX idx__public__agreement__created ON public.agreement (created);
CREATE INDEX idx__public__agreement__created_as_ts ON public.agreement (created_as_ts);
CREATE INDEX idx__public__agreement__created_ts ON public.agreement (created_ts);
CREATE INDEX idx__public__agreement__updated_ts ON public.agreement (updated_ts);
-- DROP TABLE IF EXISTS public.agreement_required_id CASCADE;
CREATE TABLE public.agreement_required_id (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES public.user(id),
agreement_id BIGINT NOT NULL REFERENCES public.agreement(id),
required_id TEXT NOT NULL,
created_ts TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_ts TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uniq__agreement_required_id UNIQUE (user_id, agreement_id, required_id)
);
CREATE INDEX idx__public__agreement_required_id__user_id ON public.agreement_required_id (user_id);
CREATE INDEX idx__public__agreement_required_id__agreement_id ON public.agreement_required_id (agreement_id);
CREATE INDEX idx__public__agreement_required_id__created_ts ON public.agreement_required_id (created_ts);
CREATE INDEX idx__public__agreement_required_id__updated_ts ON public.agreement_required_id (updated_ts);
-- DROP TABLE IF EXISTS public.purpose CASCADE;
CREATE TABLE public.purpose (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES public.user(id),
value TEXT NOT NULL,
created_ts TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_ts TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uniq__purpose UNIQUE (user_id, value)
);
CREATE INDEX idx__public__purpose__user_id ON public.purpose (user_id);
CREATE INDEX idx__public__purpose__value ON public.purpose (value);
CREATE INDEX idx__public__purpose__created_ts ON public.purpose (created_ts);
CREATE INDEX idx__public__purpose__updated_ts ON public.purpose (updated_ts);
-- DROP TABLE IF EXISTS public.agreement_purpose CASCADE;
CREATE TABLE public.agreement_purpose (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES public.user(id),
agreement_id BIGINT NOT NULL REFERENCES public.agreement(id),
purpose_id BIGINT NOT NULL REFERENCES public.purpose(id),
created_ts TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_ts TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uniq__agreement_purpose UNIQUE (user_id, agreement_id, purpose_id)
);
CREATE INDEX idx__public__agreement_purpose__user_id ON public.agreement_purpose (user_id);
CREATE INDEX idx__public__agreement_purpose__agreement_id ON public.agreement_purpose (agreement_id);
CREATE INDEX idx__public__agreement_purpose__purpose_id ON public.agreement_purpose (purpose_id);
CREATE INDEX idx__public__agreement_purpose__created_ts ON public.agreement_purpose (created_ts);
CREATE INDEX idx__public__agreement_purpose__updated_ts ON public.agreement_purpose (updated_ts);
-- DROP TABLE IF EXISTS public.caveat CASCADE;
CREATE TABLE public.caveat (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES public.user(id),
value TEXT NOT NULL,
created_ts TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_ts TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uniq__caveat UNIQUE (user_id, value)
);
CREATE INDEX idx__public__caveat__user_id ON public.caveat (user_id);
CREATE INDEX idx__public__caveat__value ON public.caveat (value);
CREATE INDEX idx__public__caveat__created_ts ON public.caveat (created_ts);
CREATE INDEX idx__public__caveat__updated_ts ON public.caveat (updated_ts);
-- DROP TABLE IF EXISTS public.agreement_caveat CASCADE;
CREATE TABLE public.agreement_caveat (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES public.user(id),
agreement_id BIGINT NOT NULL REFERENCES public.agreement(id),
caveat_id BIGINT NOT NULL REFERENCES public.caveat(id),
created_ts TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_ts TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uniq__agreement_caveat UNIQUE (user_id, agreement_id, caveat_id)
);
CREATE INDEX idx__public__agreement_caveat__user_id ON public.agreement_caveat (user_id);
CREATE INDEX idx__public__agreement_caveat__agreement_id ON public.agreement_caveat (agreement_id);
CREATE INDEX idx__public__agreement_caveat__caveat_id ON public.agreement_caveat (caveat_id);
CREATE INDEX idx__public__agreement_caveat__created_ts ON public.agreement_caveat (created_ts);
CREATE INDEX idx__public__agreement_caveat__updated_ts ON public.agreement_caveat (updated_ts);
-- DROP TABLE IF EXISTS public.role CASCADE;
CREATE TABLE public.role (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES public.user(id),
value TEXT NOT NULL,
created_ts TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_ts TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uniq__role UNIQUE (user_id, value)
);
CREATE INDEX idx__public__role__user_id ON public.role (user_id);
CREATE INDEX idx__public__role__value ON public.role (value);
CREATE INDEX idx__public__role__created_ts ON public.role (created_ts);
CREATE INDEX idx__public__role__updated_ts ON public.role (updated_ts);
-- DROP TABLE IF EXISTS public.agreement_role CASCADE;
CREATE TABLE public.agreement_role (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES public.user(id),
agreement_id BIGINT NOT NULL REFERENCES public.agreement(id),
role_id BIGINT NOT NULL REFERENCES public.role(id),
created_ts TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_ts TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uniq__agreement_role UNIQUE (user_id, agreement_id, role_id)
);
CREATE INDEX idx__public__agreement_role__user_id ON public.agreement_role (user_id);
CREATE INDEX idx__public__agreement_role__agreement_id ON public.agreement_role (agreement_id);
CREATE INDEX idx__public__agreement_role__role_id ON public.agreement_role (role_id);
CREATE INDEX idx__public__agreement_role__created_ts ON public.agreement_role (created_ts);
CREATE INDEX idx__public__agreement_role__updated_ts ON public.agreement_role (updated_ts);
-- Events
-- DROP TABLE IF EXISTS public.event_type CASCADE;
CREATE TABLE public.event_type (
id BIGSERIAL PRIMARY KEY,
value TEXT NOT NULL,
created_ts TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_ts TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx__public__event_type__value ON public.event_type (value);
CREATE INDEX idx__public__event_type__created_ts ON public.event_type (created_ts);
CREATE INDEX idx__public__event_type__updated_ts ON public.event_type (updated_ts);
INSERT INTO public.event_type (value) VALUES ('data');
-- DROP TABLE IF EXISTS public.event CASCADE;
CREATE TABLE public.event (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES public.user(id),
version SMALLINT NOT NULL,
event_id_uuid UUID UNIQUE NOT NULL,
event_type_id BIGINT NOT NULL REFERENCES public.event_type(id),
agreement_id BIGINT NOT NULL REFERENCES public.agreement(id),
sender_id TEXT NOT NULL,
recipient_id TEXT NOT NULL,
created BIGINT NOT NULL,
created_as_ts TIMESTAMPTZ NOT NULL,
created_ts TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_ts TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx__public__event__user_id ON public.event (user_id);
CREATE INDEX idx__public__event__version ON public.event (version);
CREATE INDEX idx__public__event__event_id_uuid ON public.event (event_id_uuid);
CREATE INDEX idx__public__event__event_type_id ON public.event (event_type_id);
CREATE INDEX idx__public__event__agreement_id ON public.event (agreement_id);
CREATE INDEX idx__public__event__sender_id ON public.event (sender_id);
CREATE INDEX idx__public__event__recipient_id ON public.event (recipient_id);
CREATE INDEX idx__public__event__created_ts ON public.event (created_ts);
CREATE INDEX idx__public__event__updated_ts ON public.event (updated_ts);
-- DROP TABLE IF EXISTS public.event_data CASCADE;
-- DROP SEQUENCE IF EXISTS seq__event_data__id;
-- CREATE SEQUENCE seq__event_data__id;
-- CREATE TABLE public.event_data (
-- id BIGINT NOT NULL DEFAULT nextval('seq__event_data__id'),
-- user_id BIGINT NOT NULL REFERENCES public.user(id),
-- event_id BIGINT NOT NULL REFERENCES public.event(id),
-- data TEXT NOT NULL,
-- created_ts TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- updated_ts TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- PRIMARY KEY (user_id, id)
-- ) PARTITION BY LIST (user_id);
CREATE TABLE public.event_data (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES public.user(id),
event_id BIGINT NOT NULL REFERENCES public.event(id),
data TEXT NOT NULL,
created_ts TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_ts TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx__public__event_data__event_id ON public.event_data (event_id);
CREATE INDEX idx__public__event_data__user_id ON public.event_data (user_id);
CREATE INDEX idx__public__event_data__created_ts ON public.event_data (created_ts);
CREATE INDEX idx__public__event_data__updated_ts ON public.event_data (updated_ts);
-- Signatures
-- DROP TABLE IF EXISTS public.signature CASCADE;
CREATE TABLE public.signature (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES public.user(id),
version SMALLINT NOT NULL,
signer_id TEXT NOT NULL,
signed_on BIGINT NOT NULL,
type TEXT NOT NULL,
jws TEXT NOT NULL,
role_id BIGINT REFERENCES public.role(id),
agreement_id BIGINT REFERENCES public.agreement(id),
event_id BIGINT REFERENCES public.event(id),
signed_on_as_ts TIMESTAMPTZ NOT NULL,
created_ts TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_ts TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uniq__signature__agreement UNIQUE (user_id, signer_id, agreement_id),
CONSTRAINT uniq__signature__event UNIQUE (user_id, signer_id, event_id)
);
CREATE INDEX idx__public__signature__user_id ON public.signature (user_id);
CREATE INDEX idx__public__signature__version ON public.signature (version);
CREATE INDEX idx__public__signature__signed_on ON public.signature (signed_on);
CREATE INDEX idx__public__signature__role_id ON public.signature (role_id);
CREATE INDEX idx__public__signature__agreement_id ON public.signature (agreement_id);
CREATE INDEX idx__public__signature__event_id ON public.signature (event_id);
CREATE INDEX idx__public__signature__signed_on_as_ts ON public.signature (signed_on_as_ts);
CREATE INDEX idx__public__signature__created_ts ON public.signature (created_ts);
CREATE INDEX idx__public__signature__updated_ts ON public.signature (updated_ts);

View File

@@ -0,0 +1,19 @@
-- DROP TABLE IF EXISTS public.entity CASCADE;
CREATE TABLE public.entity (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES public.user(id),
fedid_url TEXT NOT NULL,
short_name TEXT NOT NULL,
did_id TEXT NOT NULL,
control_private_key_b64u TEXT NOT NULL,
recovery_private_key_b64u TEXT NOT NULL,
did_doc JSONB NOT NULL,
created_ts TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_ts TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx__public__entity__user_id ON public.entity (user_id);
CREATE INDEX idx__public__entity__fedid_url ON public.entity (fedid_url);
CREATE INDEX idx__public__entity__short_name ON public.entity (short_name);
CREATE INDEX idx__public__entity__did_id ON public.entity (did_id);
CREATE INDEX idx__public__entity__created_ts ON public.entity (created_ts);
CREATE INDEX idx__public__entity__updated_ts ON public.entity (updated_ts);

View File

@@ -0,0 +1,14 @@
-- DROP TABLE IF EXISTS public.agreement_content CASCADE;
CREATE TABLE public.agreement_content (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT REFERENCES public.user(id),
title TEXT NOT NULL,
markdown TEXT NOT NULL,
hash TEXT NOT NULL,
created_ts TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_ts TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uniq__agreement_content UNIQUE NULLS NOT DISTINCT (user_id, title)
);
CREATE INDEX idx__public__agreement_content__user_id ON public.agreement_content (user_id);
CREATE INDEX idx__public__agreement_content__created_ts ON public.agreement_content (created_ts);
CREATE INDEX idx__public__agreement_content__updated_ts ON public.agreement_content (updated_ts);

View File

@@ -0,0 +1 @@
CREATE INDEX idx__public__audit_signature__audit_id ON public.audit_signature (audit_id);

View File

@@ -0,0 +1,31 @@
-- DROP TABLE IF EXISTS public.event_meta CASCADE;
CREATE TABLE public.event_meta (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES public.user(id),
event_id BIGINT NOT NULL REFERENCES public.event(id),
key TEXT NOT NULL,
value TEXT NOT NULL,
created_ts TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_ts TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx__public__event_meta__event_id ON public.event_meta (event_id);
CREATE INDEX idx__public__event_meta__user_id ON public.event_meta (user_id);
CREATE INDEX idx__public__event_meta__key ON public.event_meta (key);
CREATE INDEX idx__public__event_meta__value ON public.event_meta (value);
CREATE INDEX idx__public__event_meta__created_ts ON public.event_meta (created_ts);
CREATE INDEX idx__public__event_meta__updated_ts ON public.event_meta (updated_ts);
-- DROP TABLE IF EXISTS public.audit_meta CASCADE;
CREATE TABLE public.audit_meta (
id BIGSERIAL PRIMARY KEY,
audit_id BIGINT NOT NULL REFERENCES public.audit(audit_id),
key TEXT NOT NULL,
value TEXT NOT NULL,
created_ts TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_ts TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx__public__audit_meta__audit_id ON public.audit_meta (audit_id);
CREATE INDEX idx__public__audit_meta__key ON public.audit_meta (key);
CREATE INDEX idx__public__audit_meta__value ON public.audit_meta (value);
CREATE INDEX idx__public__audit_meta__created_ts ON public.audit_meta (created_ts);
CREATE INDEX idx__public__audit_meta__updated_ts ON public.audit_meta (updated_ts);

View File

@@ -0,0 +1,45 @@
-- DROP TABLE IF EXISTS public.queue_type CASCADE;
CREATE TABLE public.queue_type (
id BIGSERIAL PRIMARY KEY,
value TEXT NOT NULL UNIQUE,
created_ts TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_ts TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx__public__queue_type__value ON public.queue_type (value);
CREATE INDEX idx__public__queue_type__created_ts ON public.queue_type (created_ts);
CREATE INDEX idx__public__queue_type__updated_ts ON public.queue_type (updated_ts);
INSERT INTO public.queue_type (value) VALUES ('audit') ON CONFLICT DO NOTHING;
-- DROP TABLE IF EXISTS public.queue_url CASCADE;
CREATE TABLE public.queue_url (
id BIGSERIAL PRIMARY KEY,
value TEXT NOT NULL UNIQUE,
next_run_ts TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_ts TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_ts TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx__public__queue_url__value ON public.queue_url (value);
CREATE INDEX idx__public__queue_url__next_run_ts ON public.queue_url (next_run_ts);
CREATE INDEX idx__public__queue_url__created_ts ON public.queue_url (created_ts);
CREATE INDEX idx__public__queue_url__updated_ts ON public.queue_url (updated_ts);
-- DROP TABLE IF EXISTS public.queue CASCADE;
CREATE TABLE public.queue (
id BIGSERIAL PRIMARY KEY,
queue_type_id BIGINT NOT NULL REFERENCES public.queue_type(id),
queue_url_id BIGINT NOT NULL REFERENCES public.queue_url(id),
headers JSON,
data JSON,
run_count BIGINT NOT NULL DEFAULT 0,
last_fail TEXT,
last_run_ts TIMESTAMPTZ,
created_ts TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_ts TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx__public__queue__queue_type_id ON public.queue (queue_type_id);
CREATE INDEX idx__public__queue__run_count ON public.queue (run_count);
CREATE INDEX idx__public__queue__last_run_ts ON public.queue (last_run_ts);
CREATE INDEX idx__public__queue__created_ts ON public.queue (created_ts);
CREATE INDEX idx__public__queue__updated_ts ON public.queue (updated_ts);

View File

@@ -0,0 +1,11 @@
DO $$ BEGIN
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_name = 'agreement'
AND column_name = 'user_id'
AND is_nullable = 'NO'
) THEN
EXECUTE 'ALTER TABLE agreement ALTER COLUMN user_id DROP NOT NULL';
END IF;
END $$;

View File

@@ -0,0 +1,46 @@
ALTER TABLE public.caveat RENAME TO prohibition;
ALTER TABLE public.prohibition RENAME CONSTRAINT uniq__caveat TO uniq__prohibition;
ALTER INDEX idx__public__caveat__user_id RENAME TO idx__public__prohibition__user_id;
ALTER INDEX idx__public__caveat__value RENAME TO idx__public__prohibition__value;
ALTER INDEX idx__public__caveat__created_ts RENAME TO idx__public__prohibition__created_ts;
ALTER INDEX idx__public__caveat__updated_ts RENAME TO idx__public__prohibition__updated_ts;
ALTER TABLE public.agreement_caveat RENAME TO agreement_prohibition;
ALTER TABLE public.agreement_prohibition RENAME CONSTRAINT uniq__agreement_caveat TO uniq__agreement_prohibition;
ALTER TABLE public.agreement_prohibition RENAME COLUMN caveat_id TO prohibition_id;
ALTER TABLE public.agreement_prohibition
DROP CONSTRAINT IF EXISTS agreement_caveat_caveat_id_fkey,
ADD CONSTRAINT agreement_prohibition_prohibition_id_fkey
FOREIGN KEY (prohibition_id)
REFERENCES public.prohibition(id);
ALTER INDEX idx__public__agreement_caveat__user_id RENAME TO idx__public__agreement_prohibition__user_id;
ALTER INDEX idx__public__agreement_caveat__agreement_id RENAME TO idx__public__agreement_prohibition__agreement_id;
ALTER INDEX idx__public__agreement_caveat__caveat_id RENAME TO idx__public__agreement_prohibition__prohibition_id;
ALTER INDEX idx__public__agreement_caveat__created_ts RENAME TO idx__public__agreement_prohibition__created_ts;
ALTER INDEX idx__public__agreement_caveat__updated_ts RENAME TO idx__public__agreement_prohibition__updated_ts;
ALTER TABLE agreement_content ADD COLUMN key VARCHAR(255) UNIQUE;
CREATE INDEX idx__agreement_content__key ON public.agreement_content (key);
ALTER TABLE agreement_content ADD COLUMN agreement_uuid UUID UNIQUE NOT NULL DEFAULT '00000000-0000-0000-0000-000000000000';
ALTER TABLE purpose ALTER COLUMN user_id DROP NOT NULL;
ALTER TABLE prohibition ALTER COLUMN user_id DROP NOT NULL;
ALTER TABLE role ALTER COLUMN user_id DROP NOT NULL;
ALTER TABLE agreement_purpose ALTER COLUMN user_id DROP NOT NULL;
ALTER TABLE agreement_prohibition ALTER COLUMN user_id DROP NOT NULL;
ALTER TABLE agreement_role ALTER COLUMN user_id DROP NOT NULL;
ALTER TABLE public.purpose DROP CONSTRAINT uniq__purpose;
CREATE UNIQUE INDEX uniq__purpose ON public.purpose (COALESCE(user_id, -1), value);
ALTER TABLE public.prohibition DROP CONSTRAINT uniq__prohibition;
CREATE UNIQUE INDEX uniq__prohibition ON public.prohibition (COALESCE(user_id, -1), value);
ALTER TABLE public.role DROP CONSTRAINT uniq__role;
CREATE UNIQUE INDEX uniq__role ON public.role (COALESCE(user_id, -1), value);
ALTER TABLE public.agreement_purpose DROP CONSTRAINT uniq__agreement_purpose;
CREATE UNIQUE INDEX uniq__agreement_purpose ON public.agreement_purpose (COALESCE(user_id, -1), agreement_id, purpose_id);
ALTER TABLE public.agreement_prohibition DROP CONSTRAINT uniq__agreement_prohibition;
CREATE UNIQUE INDEX uniq__agreement_prohibition ON public.agreement_prohibition (COALESCE(user_id, -1), agreement_id, prohibition_id);
ALTER TABLE public.agreement_role DROP CONSTRAINT uniq__agreement_role;
CREATE UNIQUE INDEX uniq__agreement_role ON public.agreement_role (COALESCE(user_id, -1), agreement_id, role_id);
ALTER TABLE public.agreement ADD COLUMN context VARCHAR(255) DEFAULT 'https://protocol.jlinc.org/context/jlinc-v7.jsonld';

View File

@@ -0,0 +1 @@
ALTER TABLE public.agreement ALTER COLUMN context SET DEFAULT NULL;

View File

@@ -0,0 +1,29 @@
-- DROP TABLE IF EXISTS public.reference CASCADE;
CREATE TABLE public.reference (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES public.user(id),
uri TEXT NOT NULL UNIQUE,
created_ts TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_ts TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx__public__reference__user_id ON public.reference (user_id);
CREATE INDEX idx__public__reference__created_ts ON public.reference (created_ts);
CREATE INDEX idx__public__reference__updated_ts ON public.reference (updated_ts);
-- DROP TABLE IF EXISTS public.agreement_reference CASCADE;
CREATE TABLE public.agreement_reference (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES public.user(id),
agreement_id BIGINT NOT NULL REFERENCES public.agreement(id),
reference_id BIGINT NOT NULL REFERENCES public.reference(id),
created_ts TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_ts TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uniq__agreement_reference_id UNIQUE (user_id, agreement_id, reference_id)
);
CREATE INDEX idx__public__agreement_reference__user_id ON public.agreement_reference (user_id);
CREATE INDEX idx__public__agreement_reference__agreement_id ON public.agreement_reference (agreement_id);
CREATE INDEX idx__public__agreement_reference__reference_id ON public.agreement_reference (reference_id);
CREATE INDEX idx__public__agreement_reference__created_ts ON public.agreement_reference (created_ts);
CREATE INDEX idx__public__agreement_reference__updated_ts ON public.agreement_reference (updated_ts);
ALTER TABLE public.agreement ALTER COLUMN parent DROP NOT NULL;

View File

@@ -0,0 +1 @@
ALTER TABLE public.agreement ADD COLUMN public BOOLEAN DEFAULT FALSE;

View File

@@ -0,0 +1,3 @@
ALTER TABLE public.reference DROP CONSTRAINT reference_uri_key;
ALTER TABLE public.reference ADD CONSTRAINT uniq__agreement_reference_uri_user_id UNIQUE (uri, user_id);
CREATE INDEX idx__public__reference__uri ON public.reference (uri);

View File

@@ -0,0 +1,11 @@
-- Session store for express-session (see http/sessionStore.js).
CREATE TABLE IF NOT EXISTS public.session (
sid VARCHAR NOT NULL PRIMARY KEY,
sess JSONB NOT NULL,
expire TIMESTAMPTZ NOT NULL,
created_ts TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_ts TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx__public__session__expire ON public.session (expire);
CREATE INDEX IF NOT EXISTS idx__public__session__created_ts ON public.session (created_ts);
CREATE INDEX IF NOT EXISTS idx__public__session__updated_ts ON public.session (updated_ts);

View File

@@ -0,0 +1,14 @@
-- Per-tenant read-through cache; updated_ts drives TTL freshness.
CREATE TABLE IF NOT EXISTS public.cache (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES public.user(id),
tag TEXT NOT NULL,
data JSONB NOT NULL,
created_ts TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_ts TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uniq__cache__user_id_tag UNIQUE (user_id, tag)
);
CREATE INDEX IF NOT EXISTS idx__public__cache__user_id ON public.cache (user_id);
CREATE INDEX IF NOT EXISTS idx__public__cache__tag ON public.cache (tag);
CREATE INDEX IF NOT EXISTS idx__public__cache__created_ts ON public.cache (created_ts);
CREATE INDEX IF NOT EXISTS idx__public__cache__updated_ts ON public.cache (updated_ts);

View File

@@ -0,0 +1,21 @@
-- User-managed API keys. key_hash is a salted scrypt digest, so lookups go via
-- the indexed key_prefix rather than the hash.
CREATE TABLE IF NOT EXISTS public.api_key (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES public.user(id),
app_id BIGINT NOT NULL REFERENCES public.app(id),
label TEXT,
key_hash TEXT NOT NULL,
key_prefix TEXT NOT NULL,
expires_ts TIMESTAMPTZ,
last_used_ts TIMESTAMPTZ,
created_ts TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_ts TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx__public__api_key__key_prefix ON public.api_key (key_prefix);
CREATE INDEX IF NOT EXISTS idx__public__api_key__user_id ON public.api_key (user_id);
CREATE INDEX IF NOT EXISTS idx__public__api_key__app_id ON public.api_key (app_id);
CREATE INDEX IF NOT EXISTS idx__public__api_key__expires_ts ON public.api_key (expires_ts);
CREATE INDEX IF NOT EXISTS idx__public__api_key__last_used_ts ON public.api_key (last_used_ts);
CREATE INDEX IF NOT EXISTS idx__public__api_key__created_ts ON public.api_key (created_ts);
CREATE INDEX IF NOT EXISTS idx__public__api_key__updated_ts ON public.api_key (updated_ts);

View File

@@ -0,0 +1,6 @@
ALTER TABLE public.usage
ADD COLUMN IF NOT EXISTS event_id_uuid UUID,
ADD COLUMN IF NOT EXISTS agreement_id_uuid UUID;
CREATE INDEX IF NOT EXISTS idx__public__usage__event_id_uuid ON public.usage (event_id_uuid);
CREATE INDEX IF NOT EXISTS idx__public__usage__agreement_id_uuid ON public.usage (agreement_id_uuid);

View File

@@ -0,0 +1,30 @@
CREATE TABLE IF NOT EXISTS public.user_type (
id BIGSERIAL PRIMARY KEY,
value TEXT NOT NULL,
can_view_json BOOLEAN NOT NULL DEFAULT FALSE,
created_ts TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_ts TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- value is the natural key (the unique index also backs the ON CONFLICT below).
CREATE UNIQUE INDEX IF NOT EXISTS idx__public__user_type__value ON public.user_type (value);
CREATE INDEX IF NOT EXISTS idx__public__user_type__created_ts ON public.user_type (created_ts);
CREATE INDEX IF NOT EXISTS idx__public__user_type__updated_ts ON public.user_type (updated_ts);
-- can_view_json is the capability the app reads; roles are defined only here.
INSERT INTO public.user_type (value, can_view_json)
VALUES ('administrator', TRUE), ('full', TRUE), ('standard', FALSE)
ON CONFLICT (value) DO NOTHING;
ALTER TABLE public.user
ADD COLUMN IF NOT EXISTS user_type_id BIGINT REFERENCES public.user_type(id);
CREATE INDEX IF NOT EXISTS idx__public__user__user_type_id ON public.user (user_type_id);
-- Backfill: every existing user becomes an administrator (safe to re-run: only NULLs).
UPDATE public.user
SET user_type_id = (SELECT id FROM public.user_type WHERE value = 'administrator')
WHERE user_type_id IS NULL;
-- All rows are populated and checkUser() always supplies it going forward.
ALTER TABLE public.user ALTER COLUMN user_type_id SET NOT NULL;

View File

@@ -0,0 +1,2 @@
ALTER TABLE public.agreement_content ADD COLUMN IF NOT EXISTS remote_url TEXT;
CREATE INDEX IF NOT EXISTS idx__agreement_content__remote_url ON public.agreement_content(remote_url);

View File

@@ -0,0 +1,15 @@
-- Retry bookkeeping for remote agreement-markdown fetches. Kept separate from
-- agreement_content (which is immutable once inserted) because this state is
-- mutable: attempts and the next scheduled try are updated on each failure and
-- the row is deleted once the content is successfully cached.
CREATE TABLE IF NOT EXISTS public.agreement_fetch (
agreement_uuid UUID PRIMARY KEY,
remote_url TEXT NOT NULL,
attempts INTEGER NOT NULL DEFAULT 0,
next_attempt_ts TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_error TEXT,
created_ts TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_ts TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx__public__agreement_fetch__next_attempt_ts ON public.agreement_fetch (next_attempt_ts);

25
backend/eslint.config.js Normal file
View File

@@ -0,0 +1,25 @@
import globals from "globals";
import pluginJs from "@eslint/js";
import babelParser from "@babel/eslint-parser";
import eslintConfigPrettier from "eslint-config-prettier";
export default [
{
languageOptions: {
parser: babelParser, // Reference the @babel/eslint-parser directly
parserOptions: {
requireConfigFile: false, // Optional: set to false if you don't have a babel config file
babelOptions: {
plugins: ["@babel/plugin-syntax-import-assertions"], // Add Babel plugin for import assertions
},
},
globals: {
...globals.browser,
...globals.node, // Include Node.js global variables here
...globals.jest, // Jest globals
},
},
},
pluginJs.configs.recommended, // ESLint recommended rules
eslintConfigPrettier, // Enable Prettier integration
];

View File

@@ -0,0 +1,96 @@
import { getPool } from "../db/index.js";
import { marked } from "marked";
async function getAgreementContent(userId, hash) {
let content = '';
const client = await getPool();
try {
let sql = `
SELECT markdown
FROM agreement_content
WHERE hash = $1
`;
let values = [hash];
if (userId) {
sql += `AND user_id = $2`;
values.push(userId);
} else {
sql += `AND user_id IS NULL`;
}
const res = await client.query(sql, values);
if (res.rows.length > 0 && res.rows[0].markdown) {
content = res.rows[0].markdown;
}
} catch(e) {
console.error(e)
} finally {
await client.release();
}
return content;
}
export async function getAgreements(userId) {
let agreements = [];
const client = await getPool();
try {
let sql = `
SELECT
title,
hash
FROM agreement_content
WHERE user_id IS null
`;
let values = [];
if (userId) {
sql += `
OR user_id = $1
`;
values.push(userId);
}
sql += `
ORDER BY title ASC
`
const res = await client.query(sql, values);
if (res.rows.length > 0) {
agreements = res.rows;
}
} catch(e) {
console.error(e)
} finally {
await client.release();
}
return agreements;
}
export function routeAgreements(app) {
app.get('/agreements/:hash', async (req, res) => {
const { hash } = req.params;
const agreement = await getAgreementContent(null, hash);
res.render('agreement', {
agreement: marked(agreement),
rawUrl: `/agreements/${hash}/raw`,
});
});
app.get('/agreements/:hash/raw', async (req, res) => {
const { hash } = req.params;
const agreement = await getAgreementContent(null, hash);
res.send(`<pre>${agreement}</pre>`);
});
app.get('/agreements/:userId/:hash', async (req, res) => {
const { userId, hash } = req.params;
const agreement = await getAgreementContent(userId, hash);
res.render('agreement', {
agreement: marked(agreement),
rawUrl: `/agreements/${hash}/raw`,
});
});
app.get('/agreements/:userId/:hash/raw', async (req, res) => {
const { hash } = req.params;
const agreement = await getAgreementContent(userId, hash);
res.send(`<pre>${agreement}</pre>`);
});
}

View File

@@ -0,0 +1,108 @@
{
"swagger": "2.0",
"info": {
"version": "1",
"title": "JLINC API",
"description": "Version 1 API for the JLINC server."
},
"basePath": "/api/v1",
"schemes": ["https"],
"tags": [
{
"name": "Synchronization",
"description": "Operations related to the synchronization"
}
],
"paths": {
"/sync/update": {
"post": {
"tags": ["Synchronization"],
"summary": "Update device settings",
"description": "Updates the settings of a specified device.",
"parameters": [
{
"name": "Authorization",
"in": "header",
"required": true,
"type": "string"
},
{
"name": "Content-Type",
"in": "header",
"required": true,
"type": "string",
"default": "application/json"
},
{
"name": "body",
"in": "body",
"required": true,
"schema": {
"type": "object",
"properties": {
"deviceName": {
"type": "string",
"description": "The name of the device."
},
"savedTs": {
"type": "string",
"format": "date-time",
"description": "Timestamp when the settings were saved, in ISO 8601 format."
},
"settings": {
"type": "string",
"description": "String representing the device settings."
}
},
"required": ["deviceName", "savedTs", "settings"],
"additionalProperties": false
}
}
],
"responses": {
"200": {
"description": "Successful update",
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean",
"description": "Indicates if the update was successful",
"example": true
},
"data": {
"type": "string",
"description": "The most up to date settings data",
"example": "eyAic2V0dGluZ..."
},
"action": {
"type": "string",
"description": "What action should be taken",
"enum": ["created", "none", "existingNewer", "incomingNewer"],
"example": "created"
}
}
}
},
"400": {
"description": "Error in the request, such as invalid signature",
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean",
"description": "Indicates if the update was successful",
"example": false
},
"error": {
"type": "string",
"description": "Error message explaining what went wrong"
}
}
}
}
}
}
}
}
}

50
backend/http/apiKeys.js Normal file
View File

@@ -0,0 +1,50 @@
import { getConfig } from "../common/config.js";
import { fail, sessionRoute } from "../common/http.js";
import {
listKeys as defaultList,
generateKey as defaultGenerate,
revokeKey as defaultRevoke,
} from "../modules/core/apiKey.js";
const requireUser = (fn) => sessionRoute("apikeys", fn);
// Internal modules have no `app` row and issue no billable calls, so no key.
const mintableModules = () =>
Object.entries(getConfig().appModules)
.filter(([, m]) => !m?.internal)
.map(([type]) => type);
export function apiKeyHandlers({
listKeys = defaultList,
generateKey = defaultGenerate,
revokeKey = defaultRevoke,
} = {}) {
return {
whoami: requireUser(async (req, res) => {
res.json({ username: req.user.username, canViewJson: req.user.canViewJson, modules: mintableModules() });
}),
list: requireUser(async (req, res) => {
res.json(await listKeys(req.user.id));
}),
create: requireUser(async (req, res) => {
const { type, label, expiresTs } = req.body || {};
if (!mintableModules().includes(type)) {
return fail(res, 400, "bad_request", "unknown module");
}
if (expiresTs != null && (typeof expiresTs !== "string" || Number.isNaN(Date.parse(expiresTs)))) {
return fail(res, 400, "bad_request", "invalid expiresTs (expected an ISO date string)");
}
// Returns the raw key ONCE — the client must surface it immediately.
res.json(await generateKey(req.user.id, type, { label: label || null, expiresTs: expiresTs || null }));
}),
revoke: requireUser(async (req, res) => {
const id = parseInt(req.params.id, 10);
if (!Number.isInteger(id)) return fail(res, 400, "bad_request", "invalid id");
const ok = await revokeKey(req.user.id, id);
res.status(ok ? 200 : 404).json({ ok });
}),
};
}

View File

@@ -0,0 +1,95 @@
import { jest } from "@jest/globals";
import express from "express";
import request from "supertest";
import { getConfig } from "../common/config.js";
import { apiKeyHandlers } from "./apiKeys.js";
// getConfig() is a mutable singleton — point its enabled modules at a known set.
// `dashboard` is enabled but internal, so it must NOT appear as a mintable module.
beforeAll(() => {
getConfig().appModules = { core: {}, archive: {}, dashboard: { internal: true } };
});
function makeApp({ user = { id: 1, username: "dev" }, store } = {}) {
const app = express();
app.use((req, _res, next) => {
if (user) req.user = user;
next();
});
const h = apiKeyHandlers(store);
app.get("/api/dashboard/auth/whoami", h.whoami);
app.get("/api/dashboard/auth/keys", h.list);
app.post("/api/dashboard/auth/keys", express.json(), h.create);
app.delete("/api/dashboard/auth/keys/:id", h.revoke);
return app;
}
const okStore = () => ({
listKeys: jest.fn(async () => [{ id: 1, appType: "core", prefix: "ab12cd34", label: "ci" }]),
generateKey: jest.fn(async (_uid, type) => ({ id: 9, key: "rawsecret", prefix: "rawsecre", appType: type })),
revokeKey: jest.fn(async (_uid, id) => id === 9),
});
describe("API key management routes", () => {
it("401s when not logged in", async () => {
const res = await request(makeApp({ user: null, store: okStore() })).get("/api/dashboard/auth/keys");
expect(res.status).toBe(401);
expect(res.body.error.code).toBe("unauthorized");
});
it("whoami returns username + mintable modules (excludes internal dashboard)", async () => {
const res = await request(makeApp({ store: okStore() })).get("/api/dashboard/auth/whoami");
expect(res.status).toBe(200);
expect(res.body.username).toBe("dev");
expect(res.body.modules).toEqual(expect.arrayContaining(["core", "archive"]));
expect(res.body.modules).not.toContain("dashboard");
});
it("rejects generating a key for an internal module (dashboard)", async () => {
const store = okStore();
const res = await request(makeApp({ store })).post("/api/dashboard/auth/keys").send({ type: "dashboard" });
expect(res.status).toBe(400);
expect(store.generateKey).not.toHaveBeenCalled();
});
it("lists the user's keys (metadata only)", async () => {
const store = okStore();
const res = await request(makeApp({ store })).get("/api/dashboard/auth/keys");
expect(res.status).toBe(200);
expect(res.body[0]).toMatchObject({ prefix: "ab12cd34" });
expect(store.listKeys).toHaveBeenCalledWith(1);
});
it("generates a key for an enabled module and returns the raw key once", async () => {
const store = okStore();
const res = await request(makeApp({ store }))
.post("/api/dashboard/auth/keys")
.send({ type: "core", label: "ci" });
expect(res.status).toBe(200);
expect(res.body.key).toBe("rawsecret");
expect(store.generateKey).toHaveBeenCalledWith(1, "core", { label: "ci", expiresTs: null });
});
it("rejects generating a key for a module that is not enabled", async () => {
const store = okStore();
const res = await request(makeApp({ store })).post("/api/dashboard/auth/keys").send({ type: "nope" });
expect(res.status).toBe(400);
expect(store.generateKey).not.toHaveBeenCalled();
});
it("rejects a malformed expiresTs with 400 (not a DB-level 500)", async () => {
const store = okStore();
const res = await request(makeApp({ store }))
.post("/api/dashboard/auth/keys")
.send({ type: "core", expiresTs: "not-a-date" });
expect(res.status).toBe(400);
expect(store.generateKey).not.toHaveBeenCalled();
});
it("revokes a key (200 when deleted, 404 when not)", async () => {
const store = okStore();
const app = makeApp({ store });
expect((await request(app).delete("/api/dashboard/auth/keys/9")).status).toBe(200);
expect((await request(app).delete("/api/dashboard/auth/keys/5")).status).toBe(404);
});
});

199
backend/http/auth.js Normal file
View File

@@ -0,0 +1,199 @@
import { getConfig } from "../common/config.js";
import { getPool } from "../db/index.js";
import { verifyKey } from "../modules/core/apiKey.js";
import crypto from 'crypto';
// Bearer-token auth for the central /api/v1/* API. On success it sets
// req.session.user_id (which the core router scopes queries by). It does NOT
// populate req.user — the browser session routes (passport) own that — so the
// session-authed dashboard/key-management routes remain login-only by design.
export async function apiMiddleware(req, res, next) {
let success = false;
try {
const apiKey = req.headers['authorization']?.split(' ')[1];
if (apiKey) {
// Preferred: the hashed multi-key store (with a short in-memory cache).
const found = await verifyKey(apiKey);
if (found) {
req.session.user_id = found.user_id;
success = true;
} else {
// Fallback: the legacy single plaintext key in `auth` (issued at login).
const client = await getPool();
try {
const r = await client.query(
`SELECT au.user_id FROM public.auth au WHERE au.api_key = $1`,
[apiKey],
);
if (r.rowCount > 0) {
req.session.user_id = r.rows[0].user_id;
success = true;
}
} finally {
await client.release();
}
}
}
} catch (e) {
console.error(e);
}
if (!success)
return res.status(401).json({ error: 'API key is invalid' });
next();
}
export function getNewKey(user) {
const seed = `${user.issuer}:${user.identifier}:${user.id}:${crypto.randomBytes(16).toString('hex')}`;
const hash = crypto.createHash('sha256').update(seed).digest();
const apiKey = hash.toString('hex');
return apiKey;
}
async function createApiKeys(client, user, issuer) {
const config = getConfig();
for (const type in config.appModules) {
// Internal modules (e.g. dashboard) have no `app` row and mint no keys.
if (config.appModules[type]?.internal) continue;
const apiKey = issuer !== 'https://single'
? getNewKey(user)
: config.authModules.single[type]
await client.query(`
INSERT INTO public.auth (
user_id,
app_id,
api_key
) VALUES (
$1,
(SELECT id FROM public.app WHERE type = $2),
$3
) ON CONFLICT DO NOTHING;
`, [
user.id,
type,
apiKey,
]);
}
}
async function checkIfUserExists(client, issuer, identifier) {
return await client.query(`
SELECT
u.id,
u.photo,
u.username
FROM public.user u
WHERE u.identifier = $1
AND u.issuer = $2
`,
[
identifier,
issuer,
]);
}
export async function getUser(client, issuer, identifier) {
const res = await client.query(`
SELECT
u.id,
u.username,
u.photo,
u.issuer,
u.identifier,
COALESCE(ut.can_view_json, FALSE) AS "canViewJson",
json_agg(
jsonb_build_object(
'id', a.id,
'type', a.type,
'apiKey', au.api_key
)
) AS apps
FROM public.user u
INNER JOIN public.auth au ON u.id = au.user_id
INNER JOIN public.app a ON au.app_id = a.id
LEFT JOIN public.user_type ut ON ut.id = u.user_type_id
WHERE u.identifier = $1
AND u.issuer = $2
GROUP BY
u.id,
u.username,
u.photo,
u.issuer,
u.identifier,
ut.can_view_json
`,
[
identifier,
issuer,
]);
if (res.rowCount > 0) {
const user = res.rows[0];
return user;
}
return null;
}
export async function checkUser(type, issuer, identifier, username, photo) {
const client = await getPool();
let userExists = await checkIfUserExists(client, issuer, identifier);
if (userExists.rowCount === 0) {
if (!username || username === '') {
username = identifier;
}
// New users default to the least-privileged role; an administrator
// promotes them. Existing users keep whatever the DB already holds
// (see the 000019 backfill) — checkUser never overwrites an existing type.
const res = await client.query(`
INSERT INTO public.user (
username,
issuer,
identifier,
photo,
type,
user_type_id
) VALUES (
$1,
$2,
$3,
$4,
$5,
(SELECT id FROM public.user_type WHERE value = 'standard')
) RETURNING id;
`, [
username,
issuer,
identifier,
photo,
type,
]);
if (res.rowCount === 0) {
return null;
}
await createApiKeys(client, res.rows[0], issuer);
} else {
if (photo !== userExists.rows[0].photo || username != userExists.rows[0].username) {
await client.query(`
UPDATE public.user SET
photo = $1,
username = $2,
updated_ts = NOW()
WHERE id = $3
`, [
userExists.rows[0].photo,
userExists.rows[0].username,
userExists.rows[0].id,
]);
}
await createApiKeys(client, userExists.rows[0], issuer);
}
const user = await getUser(client, issuer, identifier);
return user;
}
export async function initModules(app, passport) {
const config = getConfig();
for (const authModule of Object.keys(config.authModules)) {
const authModulePath = `../modules/auth/${authModule}.js`;
const { initModule } = await import(authModulePath);
await initModule(app, passport);
}
}

61
backend/http/auth.test.js Normal file
View File

@@ -0,0 +1,61 @@
import { jest } from "@jest/globals";
import express from "express";
import request from "supertest";
// Manual mocks: the API-key verifier and the DB pool. No real database, no scrypt
// — apiMiddleware's branching is what we exercise here.
const verifyKey = jest.fn();
const query = jest.fn();
const release = jest.fn(async () => {});
jest.unstable_mockModule("../modules/core/apiKey.js", () => ({ verifyKey }));
jest.unstable_mockModule("../db/index.js", () => ({ getPool: async () => ({ query, release }) }));
const { apiMiddleware } = await import("./auth.js");
function makeApp() {
const app = express();
app.use((req, _res, next) => { req.session = {}; next(); });
app.get("/probe", apiMiddleware, (req, res) => res.json({ userId: req.session.user_id }));
return app;
}
beforeEach(() => {
verifyKey.mockReset();
query.mockReset();
release.mockClear();
});
describe("apiMiddleware", () => {
it("authenticates a valid hashed key and sets req.session.user_id (no legacy lookup)", async () => {
verifyKey.mockResolvedValue({ user_id: 42, app_id: 1 });
const res = await request(makeApp()).get("/probe").set("Authorization", "Bearer good-key");
expect(res.status).toBe(200);
expect(res.body.userId).toBe(42);
expect(verifyKey).toHaveBeenCalledWith("good-key");
expect(query).not.toHaveBeenCalled(); // hashed path short-circuits the legacy fallback
});
it("falls back to the legacy plaintext auth key when the hashed store misses", async () => {
verifyKey.mockResolvedValue(null);
query.mockResolvedValue({ rowCount: 1, rows: [{ user_id: 7 }] });
const res = await request(makeApp()).get("/probe").set("Authorization", "Bearer legacy-key");
expect(res.status).toBe(200);
expect(res.body.userId).toBe(7);
expect(query.mock.calls[0][1]).toEqual(["legacy-key"]);
expect(release).toHaveBeenCalledTimes(1);
});
it("rejects an unknown key with 401", async () => {
verifyKey.mockResolvedValue(null);
query.mockResolvedValue({ rowCount: 0, rows: [] });
const res = await request(makeApp()).get("/probe").set("Authorization", "Bearer bogus");
expect(res.status).toBe(401);
});
it("rejects a request with no Authorization header with 401 (no lookups)", async () => {
const res = await request(makeApp()).get("/probe");
expect(res.status).toBe(401);
expect(verifyKey).not.toHaveBeenCalled();
expect(query).not.toHaveBeenCalled();
});
});

34
backend/http/dashboard.js Normal file
View File

@@ -0,0 +1,34 @@
import { service as defaultService } from "../modules/dashboard/service.js";
import { makeDashboardApi } from "../modules/dashboard/api.js";
import { fail, sessionRoute } from "../common/http.js";
const requireUser = (fn) => sessionRoute("dashboard", fn);
// The browser gets the bare payload; the { message, data } envelope is for the
// API-key router.
const send = (res, { status, data }) =>
status === 200
? res.json(data)
: fail(res, status, status === 404 ? "not_found" : "bad_request", data?.error);
export function dashboardHandlers({ service = defaultService } = {}) {
const api = makeDashboardApi(service);
const route = (fn) => requireUser(async (req, res) => send(res, await fn(req)));
return {
summary: route((req) => api.summary(req.query, req.user.id)),
transactions: route((req) => api.transactions(req.query, req.user.id)),
series: route((req) => api.series(req.query, req.user.id)),
verify: route((req) => api.verify(req.query, req.user.id)),
// View JSON exposes the raw payload (possibly private); the can_view_json
// capability comes from the user's role in the DB (see getUser).
event: requireUser(async (req, res) => {
if (!req.user.canViewJson) return fail(res, 403, "forbidden", "not permitted to view JSON");
send(res, await api.event({ eventUuid: req.params.eventUuid }, req.user.id));
}),
agreement: route((req) => api.agreement({ agreementUuid: req.params.agreementUuid }, req.user.id)),
details: requireUser(async (req, res) => {
if (!req.user.canViewJson) return fail(res, 403, "forbidden", "not permitted to view JSON");
send(res, await api.details({ eventUuid: req.params.eventUuid }, req.user.id));
})
};
}

View File

@@ -0,0 +1,169 @@
import { jest } from "@jest/globals";
import express from "express";
import request from "supertest";
import { dashboardHandlers } from "./dashboard.js";
// Mount the dashboard routes on a bare app with an injectable fake service and a
// settable user, so we can test auth, validation, response shaping, and the
// error envelope without a database.
function makeApp({ user = { id: 1, canViewJson: true }, service } = {}) {
const app = express();
app.use((req, _res, next) => {
if (user) req.user = user;
next();
});
const h = dashboardHandlers({ service });
app.get("/api/dashboard/summary", h.summary);
app.get("/api/dashboard/transactions", h.transactions);
app.get("/api/dashboard/series", h.series);
app.get("/api/dashboard/verify", h.verify);
app.get("/api/dashboard/transactions/:eventUuid", h.event);
return app;
}
const okService = () => ({
summary: jest.fn(async () => ({ apiUsage: { core: 2, audit: 1, total: 3 }, apiKeys: 4, endpoints: 2, storage: {} })),
transactions: jest.fn(async (_uid, opts) => ({ rows: [{ eventUuid: "e1" }], total: 1, limit: opts.limit, offset: opts.offset })),
series: jest.fn(async () => ({
usage: [{ date: "2026-06-01", core: 1, audit: 0, total: 1 }],
storage: [{ date: "2026-06-01", coreDisk: 10, auditDisk: 0, totalDisk: 10 }],
})),
event: jest.fn(async () => ({ eventId: "3fa85f64-5717-4562-b3fc-2c963f66afa6", data: { a: 1 } })),
verify: jest.fn(async (_uid, { eventUuids, auditIds }) => ({
results: [
...eventUuids.map((eventUuid) => ({ eventUuid, auditId: null, status: "verified", verified: true })),
...auditIds.map((auditId) => ({ eventUuid: "e1", auditId, status: "verified", verified: true })),
],
})),
});
describe("GET /api/dashboard/* auth", () => {
it("returns 401 with an error envelope when not logged in", async () => {
const res = await request(makeApp({ user: null, service: okService() })).get("/api/dashboard/summary");
expect(res.status).toBe(401);
expect(res.body).toEqual({ error: { code: "unauthorized", message: expect.any(String) } });
});
});
describe("GET /api/dashboard/summary", () => {
it("calls the service with the user id + window and returns the payload", async () => {
const service = okService();
const res = await request(makeApp({ service })).get("/api/dashboard/summary?from=2026-01-01&to=2026-01-31");
expect(res.status).toBe(200);
expect(res.body.apiUsage).toEqual({ core: 2, audit: 1, total: 3 });
expect(service.summary).toHaveBeenCalledWith(1, { from: "2026-01-01", to: "2026-01-31" });
});
});
describe("GET /api/dashboard/transactions", () => {
it("passes validated/normalized options to the service", async () => {
const service = okService();
const res = await request(makeApp({ service })).get("/api/dashboard/transactions?type=audit&sort=size&dir=asc&limit=10");
expect(res.status).toBe(200);
expect(res.body).toMatchObject({ total: 1, limit: 10, offset: 0 });
expect(service.transactions).toHaveBeenCalledWith(1, expect.objectContaining({ type: "audit", sort: "size", dir: "asc", limit: 10 }));
});
it("returns 400 for an invalid type and does not call the service", async () => {
const service = okService();
const res = await request(makeApp({ service })).get("/api/dashboard/transactions?type=evil");
expect(res.status).toBe(400);
expect(res.body.error.code).toBe("bad_request");
expect(service.transactions).not.toHaveBeenCalled();
});
it("returns 400 for an unknown sort key (injection guard)", async () => {
const res = await request(makeApp({ service: okService() })).get("/api/dashboard/transactions?sort=sender;DROP");
expect(res.status).toBe(400);
});
});
describe("GET /api/dashboard/series (combined usage + storage)", () => {
it("returns both usage and storage series for the window in one response", async () => {
const service = okService();
const res = await request(makeApp({ service })).get("/api/dashboard/series?from=2026-06-01&to=2026-06-03");
expect(res.status).toBe(200);
expect(res.body.usage).toHaveLength(1);
expect(res.body.storage[0]).toMatchObject({ totalDisk: 10 });
expect(service.series).toHaveBeenCalledWith(1, { from: "2026-06-01", to: "2026-06-03" });
});
});
describe("GET /api/dashboard/verify", () => {
const good = "3fa85f64-5717-4562-b3fc-2c963f66afa6";
it("verifies only the valid UUIDs from the ids list (core tab)", async () => {
const service = okService();
const res = await request(makeApp({ service })).get(`/api/dashboard/verify?ids=${good},junk`);
expect(res.status).toBe(200);
expect(res.body.results).toHaveLength(1);
expect(service.verify).toHaveBeenCalledWith(1, { eventUuids: [good], auditIds: [] });
});
// The audit tab targets audit RECORDS: two rows of one event must be two
// separate targets, or verifying one reports a verdict on both.
it("passes audit ids straight through as their own targets (audit tab)", async () => {
const service = okService();
const res = await request(makeApp({ service })).get("/api/dashboard/verify?auditIds=9,10");
expect(res.status).toBe(200);
expect(service.verify).toHaveBeenCalledWith(1, { eventUuids: [], auditIds: [9, 10] });
expect(res.body.results).toHaveLength(2);
});
it("returns empty results when no valid targets are supplied", async () => {
const service = okService();
const res = await request(makeApp({ service })).get("/api/dashboard/verify?ids=junk&auditIds=nope");
expect(res.status).toBe(200);
expect(res.body.results).toEqual([]);
expect(service.verify).toHaveBeenCalledWith(1, { eventUuids: [], auditIds: [] });
});
});
describe("GET /api/dashboard/transactions/:eventUuid", () => {
it("400s on a non-uuid id", async () => {
const res = await request(makeApp({ service: okService() })).get("/api/dashboard/transactions/not-a-uuid");
expect(res.status).toBe(400);
});
it("404s when the event is not found", async () => {
const service = okService();
service.event = jest.fn(async () => null);
const res = await request(makeApp({ service })).get("/api/dashboard/transactions/3fa85f64-5717-4562-b3fc-2c963f66afa6");
expect(res.status).toBe(404);
expect(res.body.error.code).toBe("not_found");
});
it("returns the record when found", async () => {
const res = await request(makeApp({ service: okService() })).get("/api/dashboard/transactions/3fa85f64-5717-4562-b3fc-2c963f66afa6");
expect(res.status).toBe(200);
expect(res.body.data).toEqual({ a: 1 });
});
it("403s for a user without View JSON, before touching the service", async () => {
const service = okService();
const res = await request(makeApp({ user: { id: 1, canViewJson: false }, service }))
.get("/api/dashboard/transactions/3fa85f64-5717-4562-b3fc-2c963f66afa6");
expect(res.status).toBe(403);
expect(res.body.error.code).toBe("forbidden");
expect(service.event).not.toHaveBeenCalled();
});
it("allows a user with View JSON", async () => {
const res = await request(makeApp({ user: { id: 1, canViewJson: true }, service: okService() }))
.get("/api/dashboard/transactions/3fa85f64-5717-4562-b3fc-2c963f66afa6");
expect(res.status).toBe(200);
});
});
describe("error envelope on a thrown service error", () => {
it("returns 500 with the envelope and logs", async () => {
const service = okService();
service.summary = jest.fn(async () => { throw new Error("boom"); });
const spy = jest.spyOn(console, "error").mockImplementation(() => {});
const res = await request(makeApp({ service })).get("/api/dashboard/summary");
expect(res.status).toBe(500);
expect(res.body).toEqual({ error: { code: "internal", message: "Internal error" } });
expect(spy).toHaveBeenCalled();
spy.mockRestore();
});
});

View File

@@ -0,0 +1,100 @@
import { service as defaultService } from "../modules/dashboard/service.js";
import { getConfig } from "../common/config.js";
// Same interface as the local service, so it is a drop-in for it.
//
// Tenant scoping: locally, every call carries the session's own user id.
// Remotely, the API KEY is the scope — we deliberately send NO user id, since a
// local primary key is meaningless on another operator and must never be
// trusted as a cross-operator identity.
export function makePassthrough({ service = defaultService, config = getConfig(), fetchImpl = fetch } = {}) {
const sources = () => config.dashboard || { core: {}, audit: {} };
const remote = (s) => sources()[s] && sources()[s].url;
const federated = () => Boolean(remote("core") || remote("audit"));
async function fromSource(source, endpoint, params, localCall) {
const src = sources()[source];
if (!src || !src.url) return localCall();
const res = await fetchImpl(`${src.url}/api/v1/data/dashboard/${endpoint}`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${src.key}` },
body: JSON.stringify(params),
});
if (!res.ok) throw new Error(`federation: ${source}/${endpoint} -> ${res.status}`);
return res.json();
}
const num = (v) => Number(v) || 0;
function mergeSummary(core, audit) {
const cu = core.apiUsage || {}, au = audit.apiUsage || {};
const cs = core.storage || {}, as = audit.storage || {};
const split = (c, a) => ({ core: num(c), audit: num(a), total: num(c) + num(a) });
return {
apiUsage: split(cu.core, au.audit),
apiKeys: core.apiKeys,
endpoints: core.endpoints,
storage: {
onDisk: split(cs.onDisk?.core, as.onDisk?.audit),
logical: split(cs.logical?.core, as.logical?.audit),
},
};
}
function mergeByDate(coreSeries = [], auditSeries = [], coreKey, auditKey, totalKey) {
const auditByDate = new Map(auditSeries.map((r) => [r.date, r]));
return coreSeries.map((r) => {
const a = auditByDate.get(r.date) || {};
const core = num(r[coreKey]);
const audit = num(a[auditKey]);
return { date: r.date, [coreKey]: core, [auditKey]: audit, [totalKey]: core + audit };
});
}
return {
async summary(userId, window) {
if (!federated()) return service.summary(userId, window);
const [core, audit] = await Promise.all([
fromSource("core", "summary", window, () => service.summary(userId, window)),
fromSource("audit", "summary", window, () => service.summary(userId, window)),
]);
return mergeSummary(core, audit);
},
async transactions(userId, opts) {
const source = opts.type === "audit" ? "audit" : "core";
return fromSource(source, "transactions", opts, () => service.transactions(userId, opts));
},
async series(userId, window) {
if (!federated()) return service.series(userId, window);
const [core, audit] = await Promise.all([
fromSource("core", "series", window, () => service.series(userId, window)),
fromSource("audit", "series", window, () => service.series(userId, window)),
]);
return {
usage: mergeByDate(core.usage, audit.usage, "core", "audit", "total"),
storage: mergeByDate(core.storage, audit.storage, "coreDisk", "auditDisk", "totalDisk"),
};
},
// Audit ids belong to the audit source that served the listing.
async verify(userId, targets) {
const { eventUuids = [], auditIds = [] } = targets || {};
const params = { ids: eventUuids.join(","), auditIds: auditIds.join(",") };
return fromSource("audit", "verify", params, () => service.verify(userId, targets));
},
async event(userId, eventUuid) {
return fromSource("core", "event", { eventUuid }, () => service.event(userId, eventUuid));
},
async agreement(userId, agreementUuid) {
return fromSource("core", "agreement", { agreementUuid }, () => service.agreement(userId, agreementUuid));
},
async details(userId, eventUuid) {
return fromSource("core", "details", { eventUuid }, () => service.details(userId, eventUuid))
}
};
}

View File

@@ -0,0 +1,97 @@
import { jest } from "@jest/globals";
import { makePassthrough } from "./dashboardPassthrough.js";
const LOCAL = { core: { url: null, key: null }, audit: { url: null, key: null } };
const FED = { core: { url: "http://core", key: "ck" }, audit: { url: "http://audit", key: "ak" } };
const localService = () => ({
summary: jest.fn(async () => ({ apiUsage: { core: 2, audit: 1, total: 3 }, apiKeys: 4, endpoints: 2, storage: {} })),
transactions: jest.fn(async () => ({ rows: [], total: 0 })),
series: jest.fn(async () => ({ usage: [], storage: [] })),
verify: jest.fn(async () => ({ results: [] })),
event: jest.fn(async () => ({ eventId: "x" })),
});
describe("passthrough — local mode (no remote sources)", () => {
it("serves every endpoint from the local service and never fetches", async () => {
const service = localService();
const fetchImpl = jest.fn();
const p = makePassthrough({ service, config: { dashboard: LOCAL }, fetchImpl });
expect(await p.summary(7, { from: "a", to: "b" })).toMatchObject({ apiKeys: 4 });
await p.transactions(7, { type: "audit" });
await p.series(7, {});
expect(service.summary).toHaveBeenCalled();
expect(service.transactions).toHaveBeenCalled();
expect(fetchImpl).not.toHaveBeenCalled();
});
it("scopes every local call to the caller's own user id (per-tenant isolation)", async () => {
const service = localService();
const p = makePassthrough({ service, config: { dashboard: LOCAL }, fetchImpl: jest.fn() });
await p.summary(7, { from: "a", to: "b" });
await p.transactions(9, { type: "core" });
await p.event(9, "uuid-1");
// The passthrough never substitutes another user's id — each call carries the
// id it was handed (which the route sets from req.user.id), so user 7 can
// never be served user 9's rows and vice versa.
expect(service.summary).toHaveBeenCalledWith(7, { from: "a", to: "b" });
expect(service.transactions.mock.calls[0][0]).toBe(9);
expect(service.event).toHaveBeenCalledWith(9, "uuid-1");
});
});
describe("passthrough — federated mode", () => {
const fetchImpl = jest.fn(async (url) => ({
ok: true,
json: async () =>
url.includes("//core")
? { apiUsage: { core: 10, audit: 0, total: 10 }, apiKeys: 5, endpoints: 2, storage: { onDisk: { core: 100, audit: 0, total: 100 }, logical: { core: 100, audit: 0, total: 100 } } }
: { apiUsage: { core: 0, audit: 7, total: 7 }, apiKeys: 0, endpoints: 0, storage: { onDisk: { core: 0, audit: 50, total: 50 }, logical: { core: 0, audit: 50, total: 50 } } },
}));
beforeEach(() => fetchImpl.mockClear());
it("merges summary: core fields from core source, audit fields from audit source", async () => {
const p = makePassthrough({ service: {}, config: { dashboard: FED }, fetchImpl });
const out = await p.summary(7, { from: "a", to: "b" });
expect(out.apiUsage).toEqual({ core: 10, audit: 7, total: 17 });
expect(out.storage.onDisk).toEqual({ core: 100, audit: 50, total: 150 });
expect(out.apiKeys).toBe(5); // from the core source
expect(fetchImpl).toHaveBeenCalledTimes(2);
});
it("forwards with the source's API key and NO user id in the body (the key is the tenant scope)", async () => {
const p = makePassthrough({ service: {}, config: { dashboard: FED }, fetchImpl });
await p.summary(7, { from: "a", to: "b" });
const coreCall = fetchImpl.mock.calls.find(([u]) => u.includes("//core"));
expect(coreCall[0]).toBe("http://core/api/v1/data/dashboard/summary");
expect(coreCall[1].headers.Authorization).toBe("Bearer ck");
const body = JSON.parse(coreCall[1].body);
expect(body).toEqual({ from: "a", to: "b" }); // only the window — never a userId
expect(body).not.toHaveProperty("userId");
});
it("routes the audit transactions tab to the audit source", async () => {
const p = makePassthrough({ service: {}, config: { dashboard: FED }, fetchImpl });
await p.transactions(7, { type: "audit", limit: 50, offset: 0 });
const [url] = fetchImpl.mock.calls[0];
expect(url).toBe("http://audit/api/v1/data/dashboard/transactions");
});
it("merges the combined usage+storage series by date", async () => {
const seriesFetch = jest.fn(async (url) => ({
ok: true,
json: async () =>
url.includes("//core")
? { usage: [{ date: "2026-06-01", core: 3, audit: 0, total: 3 }], storage: [{ date: "2026-06-01", coreDisk: 100, auditDisk: 0, totalDisk: 100 }] }
: { usage: [{ date: "2026-06-01", core: 0, audit: 5, total: 5 }], storage: [{ date: "2026-06-01", coreDisk: 0, auditDisk: 50, totalDisk: 50 }] },
}));
const p = makePassthrough({ service: {}, config: { dashboard: FED }, fetchImpl: seriesFetch });
const out = await p.series(7, { from: "a", to: "b" });
expect(out.usage).toEqual([{ date: "2026-06-01", core: 3, audit: 5, total: 8 }]);
expect(out.storage).toEqual([{ date: "2026-06-01", coreDisk: 100, auditDisk: 50, totalDisk: 150 }]);
});
});

130
backend/http/index.js Normal file
View File

@@ -0,0 +1,130 @@
import bodyParser from "body-parser";
import { initModules, apiMiddleware } from "./auth.js";
import { loadPep } from "../modules/pep/index.js";
import swaggerUi from "swagger-ui-express";
import swaggerDocument from "./api/v1/swagger.json" with { type: "json" };
import { getConfig } from "../common/config.js";
import { core } from "../modules/core/index.js"
import { getAgreements } from "../http/agreements.js"
import express from "express";
import session from "express-session";
import { PgSessionStore } from "./sessionStore.js";
import passport from "passport";
import { refresh } from "./refresh.js";
import { logout } from "./logout.js";
import { logRequest } from "./logging.js";
import { routeAgreements } from "./agreements.js";
import { dashboardHandlers } from "./dashboard.js";
import { makePassthrough } from "./dashboardPassthrough.js";
import { apiKeyHandlers } from "./apiKeys.js";
import { getUsage } from "../modules/core/usage.js";
import path from "path";
async function render(view, res, config) {
try {
res.render(view, {
config,
});
} catch (e) {
console.error(e);
}
}
async function renderPrivate(view, req, res, config) {
try {
if (!req.user) {
return res.redirect('/');
}
const now = new Date();
const begin = new Date(now.getFullYear(), now.getMonth(), 1);
const end = new Date(now.getFullYear(), now.getMonth() + 1, 0);
const usage = await getUsage(req.user, begin, end);
const agreements = await getAgreements(req?.user?.id);
res.render(view, {
config,
agreements,
user: req.user,
usage,
});
} catch (e) {
console.error(e);
}
}
async function renderPrivateUI(req, res, config) {
try {
if (!req.user) {
return res.redirect('/');
}
const uiPath = path.join(process.cwd(), "ui", "index.html");
res.sendFile(uiPath);
} catch (e) {
console.error(e);
}
}
export async function initHTTP(app) {
const config = getConfig();
passport.serializeUser(function (user, done) {
done(null, user);
});
passport.deserializeUser(function (user, done) {
done(null, user);
});
const sess = {
secret: config.secureSecret,
resave: false,
saveUninitialized: false,
store: new PgSessionStore(),
cookie: { httpOnly: true, sameSite: 'lax' }, // sameSite=lax: CSRF defense
}
if (app.get('env') === 'production') {
app.set('trust proxy', 1) // trust first proxy
sess.cookie.secure = true // serve secure cookies over HTTPS
}
app.use(session(sess));
app.use(passport.initialize());
app.use(passport.session());
app.use(express.static("./http/public"));
app.set('views', './http/views');
app.set('view engine', 'ejs');
await initModules(app, passport);
logRequest(app);
routeAgreements(app);
await loadPep(app);
app.get("/", (req, res) => render('login', res, config));
app.get("/dashboard", (req, res) => renderPrivate('dashboard', req, res, config));
app.get("/refresh", refresh);
app.post('/logout', logout);
// UI
app.get("/home", (req, res) => renderPrivateUI(req, res, config));
const dashboard = dashboardHandlers({ service: makePassthrough() });
app.get("/api/dashboard/summary", dashboard.summary);
app.get("/api/dashboard/transactions", dashboard.transactions);
app.get("/api/dashboard/series", dashboard.series);
app.get("/api/dashboard/verify", dashboard.verify);
app.get("/api/dashboard/transactions/:eventUuid", dashboard.event);
app.get("/api/dashboard/agreement/:agreementUuid", dashboard.agreement);
app.get("/api/dashboard/details/:eventUuid", dashboard.details);
const apiKeys = apiKeyHandlers();
app.get("/api/dashboard/auth/whoami", apiKeys.whoami);
app.get("/api/dashboard/auth/keys", apiKeys.list);
app.post("/api/dashboard/auth/keys", express.json(), apiKeys.create);
app.delete("/api/dashboard/auth/keys/:id", apiKeys.revoke);
app.post(/^\/api\/v1\/.*$/, bodyParser.json(), apiMiddleware, core.post);
// app.use("/api/v1", swaggerUi.serve, swaggerUi.setup(swaggerDocument));
}

40
backend/http/logging.js Normal file
View File

@@ -0,0 +1,40 @@
import { getConfig } from "../common/config.js";
export function logRequest(app) {
const config = getConfig();
app.use((req, res, next) => {
const originalSend = res.send;
res.send = function (body) {
res.body = body; // Store the response body for logging
try {
return originalSend.apply(res, arguments); // Proceed with sending the response
} catch(e) {
console.error(e)
}
};
res.on("finish", () => {
let output = `${req.method} - ${res.statusCode} - ${req.url}`;
if (req.apiMessage) {
output += ` - ${req.apiMessage}`;
delete req.apiMessage;
}
console.log(output);
if (res.statusCode != 200 || config.debug) {
let body;
try {
body = JSON.parse(res.body);
} catch (e) {
body = {};
}
if (body?.error) {
console.log(`${req.method} - ${res.statusCode} - ${req.url} - ERROR: ${body.error}`);
return;
}
if (config.debug) {
console.log(JSON.stringify(body, null, 2));
}
}
});
next();
});
}

22
backend/http/logout.js Normal file
View File

@@ -0,0 +1,22 @@
import { getConfig } from "../common/config.js";
export async function logout(req, res, next) {
const strategy = req.session.authStrategy ?`${req.session.authStrategy}` : null;
req.logout(function (err) {
if (err) { return next(err); }
req.session.destroy(function (err) {
if (err) { return next(err); }
if (strategy) {
const config = getConfig();
const strategyConfig = config.authModules[strategy];
if (strategyConfig && strategyConfig.logoutURL) {
res.redirect(strategyConfig.logoutURL);
} else {
res.redirect('/');
}
} else {
res.redirect('/');
}
});
});
}

View File

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

After

Width:  |  Height:  |  Size: 1.7 KiB

28
backend/http/refresh.js Normal file
View File

@@ -0,0 +1,28 @@
import { getPool } from "../db/index.js";
import { getNewKey, getUser } from "./auth.js";
export async function refresh(req, res) {
try {
const client = await getPool();
const apiKey = getNewKey(req.user);
await client.query(`
UPDATE public.auth SET
api_key = $1
WHERE user_id = $2
AND app_id = (
SELECT id FROM public.app WHERE type = $3
);
`, [
apiKey,
req.user.id,
req.query.app,
]);
const user = await getUser(client, req.user.issuer, req.user.identifier);
req.session.passport.user = user;
} catch (e) {
console.error(e);
} finally {
res.redirect('/dashboard');
}
}

View File

@@ -0,0 +1,115 @@
import session from "express-session";
import { getPool } from "../db/index.js";
const ONE_DAY_MS = 86_400_000;
const DEFAULT_PRUNE_INTERVAL_MS = 15 * 60 * 1000; // 15 minutes
// Memory is per-process: a session mutated on another worker (e.g. a logout)
// can be served stale here for at most one TTL. Keep it short.
const DEFAULT_MEMORY_TTL_MS = 60 * 1000; // 60 seconds
export const expiryOf = (sess) =>
new Date(sess?.cookie?.expires ?? Date.now() + ONE_DAY_MS);
// In-memory cache in front of Postgres: sessions survive restarts and are
// shared across workers, while the memory map serves the read-heavy hot path.
export class PgSessionStore extends session.Store {
constructor({ pruneIntervalMs = DEFAULT_PRUNE_INTERVAL_MS, memoryTtlMs = DEFAULT_MEMORY_TTL_MS } = {}) {
super();
// sid -> { sess, expire (ms epoch), freshUntil (ms epoch) }
this._mem = new Map();
this._memoryTtlMs = memoryTtlMs;
// unref'd so the timer never keeps the process alive.
if (pruneIntervalMs > 0) {
this._pruneTimer = setInterval(() => this.prune(), pruneIntervalMs);
this._pruneTimer.unref?.();
}
}
async _withPg(fn) {
const client = await getPool();
try {
return await fn(client);
} finally {
await client.release();
}
}
get(sid, cb) {
const now = Date.now();
const hit = this._mem.get(sid);
if (hit) {
if (hit.expire <= now) {
this._mem.delete(sid);
return cb(null, null);
}
if (hit.freshUntil > now) return cb(null, hit.sess); // fast path: no DB query
}
this._withPg((c) =>
c.query(`SELECT sess, expire FROM session WHERE sid = $1 AND expire > NOW()`, [sid]),
)
.then((res) => {
const row = res.rows[0];
if (!row) {
this._mem.delete(sid);
return cb(null, null);
}
this._mem.set(sid, {
sess: row.sess,
expire: new Date(row.expire).getTime(),
freshUntil: now + this._memoryTtlMs,
});
cb(null, row.sess);
})
.catch(cb);
}
set(sid, sess, cb = () => {}) {
const expire = expiryOf(sess);
this._mem.set(sid, { sess, expire: expire.getTime(), freshUntil: Date.now() + this._memoryTtlMs });
this._withPg((c) =>
c.query(
`INSERT INTO session (sid, sess, expire) VALUES ($1, $2::jsonb, $3)
ON CONFLICT (sid) DO UPDATE SET sess = EXCLUDED.sess, expire = EXCLUDED.expire, updated_ts = NOW()`,
[sid, sess, expire],
),
)
.then(() => cb(null))
.catch(cb);
}
destroy(sid, cb = () => {}) {
this._mem.delete(sid);
this._withPg((c) => c.query(`DELETE FROM session WHERE sid = $1`, [sid]))
.then(() => cb(null))
.catch(cb);
}
touch(sid, sess, cb = () => {}) {
const expire = expiryOf(sess);
const hit = this._mem.get(sid);
if (hit) {
hit.expire = expire.getTime();
hit.freshUntil = Date.now() + this._memoryTtlMs;
}
this._withPg((c) =>
c.query(`UPDATE session SET expire = $2, updated_ts = NOW() WHERE sid = $1`, [sid, expire]),
)
.then(() => cb(null))
.catch(cb);
}
// Errors are swallowed: runs on a timer with no caller.
async prune() {
const now = Date.now();
for (const [sid, v] of this._mem) if (v.expire <= now) this._mem.delete(sid);
try {
await this._withPg((c) => c.query(`DELETE FROM session WHERE expire <= NOW()`));
} catch {
/* ignore */
}
}
stopPruning() {
if (this._pruneTimer) clearInterval(this._pruneTimer);
}
}

View File

@@ -0,0 +1,160 @@
import { jest } from "@jest/globals";
// Manual DB mock: getPool() returns whatever fake client the current test set.
let client;
const getPool = jest.fn(async () => client);
jest.unstable_mockModule("../db/index.js", () => ({ getPool }));
const { PgSessionStore, expiryOf } = await import("./sessionStore.js");
const ONE_DAY_MS = 86_400_000;
// A fake pooled client whose query() returns `rows` and that records release().
function mockClient(rows = []) {
return {
query: jest.fn(async () => ({ rows })),
release: jest.fn(async () => {}),
};
}
// Store with the prune timer disabled; memoryTtlMs chosen per test.
const makeStore = (opts = {}) => new PgSessionStore({ pruneIntervalMs: 0, ...opts });
// Promisify express-session's node-style callbacks for cleaner assertions.
const call = (fn) => new Promise((resolve, reject) =>
fn((err, res) => (err ? reject(err) : resolve(res))));
beforeEach(() => {
getPool.mockClear();
client = mockClient();
});
describe("expiryOf", () => {
it("uses the cookie's expiry when present", () => {
const when = new Date("2030-01-01T00:00:00Z");
expect(expiryOf({ cookie: { expires: when } }).getTime()).toBe(when.getTime());
});
it("defaults to ~one day out when no cookie expiry", () => {
const before = Date.now();
const got = expiryOf({}).getTime();
expect(got).toBeGreaterThanOrEqual(before + ONE_DAY_MS - 50);
expect(got).toBeLessThanOrEqual(Date.now() + ONE_DAY_MS + 50);
});
});
describe("PgSessionStore memory + PG fallback", () => {
it("get() falls back to Postgres on a cold cache and filters expired rows in SQL", async () => {
client = mockClient([{ sess: { user_id: 7 }, expire: new Date(Date.now() + ONE_DAY_MS).toISOString() }]);
const store = makeStore();
const sess = await call((cb) => store.get("sid-1", cb));
expect(sess).toEqual({ user_id: 7 });
expect(getPool).toHaveBeenCalledTimes(1);
const [sql, params] = client.query.mock.calls[0];
expect(sql).toContain("expire > NOW()");
expect(params).toEqual(["sid-1"]);
expect(client.release).toHaveBeenCalledTimes(1);
});
it("get() serves a warm in-memory session WITHOUT querying Postgres", async () => {
const store = makeStore({ memoryTtlMs: 10_000 });
await call((cb) => store.set("sid-2", { user_id: 1, cookie: {} }, cb)); // write-through populates memory
getPool.mockClear();
const sess = await call((cb) => store.get("sid-2", cb));
expect(sess).toMatchObject({ user_id: 1 });
expect(getPool).not.toHaveBeenCalled(); // the whole point: no per-request query
});
it("get() re-reads Postgres once the in-memory copy goes stale", async () => {
client = mockClient([{ sess: { user_id: 5 }, expire: new Date(Date.now() + ONE_DAY_MS).toISOString() }]);
const store = makeStore({ memoryTtlMs: 0 }); // memory never counts as fresh
await call((cb) => store.set("sid", { user_id: 5, cookie: {} }, cb));
getPool.mockClear();
const sess = await call((cb) => store.get("sid", cb));
expect(getPool).toHaveBeenCalledTimes(1); // stale -> fell back to Postgres
expect(sess).toEqual({ user_id: 5 });
});
it("get() drops an expired in-memory entry without a query", async () => {
const store = makeStore({ memoryTtlMs: 10_000 });
await call((cb) => store.set("exp", { user_id: 1, cookie: { expires: new Date(Date.now() - 1000) } }, cb));
getPool.mockClear();
const sess = await call((cb) => store.get("exp", cb));
expect(sess).toBeNull();
expect(getPool).not.toHaveBeenCalled();
});
it("get() returns null when no row matches", async () => {
client = mockClient([]);
const sess = await call((cb) => makeStore().get("missing", cb));
expect(sess).toBeNull();
});
it("set() write-through upserts to Postgres (bumping updated_ts) and populates memory", async () => {
const store = makeStore({ memoryTtlMs: 10_000 });
const when = new Date("2030-06-01T00:00:00Z");
await call((cb) => store.set("sid-3", { cookie: { expires: when }, user_id: 1 }, cb));
const [sql, params] = client.query.mock.calls[0];
expect(sql).toContain("ON CONFLICT (sid) DO UPDATE");
expect(sql).toContain("updated_ts = NOW()");
expect(params[0]).toBe("sid-3");
expect(params[2]).toEqual(when);
getPool.mockClear();
const sess = await call((cb) => store.get("sid-3", cb));
expect(getPool).not.toHaveBeenCalled(); // served from memory after the write-through
expect(sess).toMatchObject({ user_id: 1 });
});
it("destroy() removes the session from memory and Postgres", async () => {
const store = makeStore({ memoryTtlMs: 10_000 });
await call((cb) => store.set("sid-4", { user_id: 1, cookie: {} }, cb));
client.query.mockClear();
await call((cb) => store.destroy("sid-4", cb));
expect(client.query.mock.calls[0][0]).toContain("DELETE FROM session WHERE sid = $1");
expect(client.query.mock.calls[0][1]).toEqual(["sid-4"]);
// Memory was cleared -> the next get falls back to Postgres (now empty).
client = mockClient([]);
getPool.mockClear();
expect(await call((cb) => store.get("sid-4", cb))).toBeNull();
expect(getPool).toHaveBeenCalledTimes(1);
});
it("touch() bumps only the expiry", async () => {
await call((cb) => makeStore().touch("sid-5", {}, cb));
expect(client.query.mock.calls[0][0]).toContain("UPDATE session SET expire = $2");
});
it("prune() sweeps expired rows from Postgres", async () => {
const store = makeStore();
await store.prune();
expect(client.query.mock.calls[0][0]).toContain("DELETE FROM session WHERE expire <= NOW()");
expect(client.release).toHaveBeenCalledTimes(1);
});
it("releases the client and surfaces the error when a query fails", async () => {
client = {
query: jest.fn(async () => { throw new Error("db down"); }),
release: jest.fn(async () => {}),
};
await expect(call((cb) => makeStore({ memoryTtlMs: 0 }).get("sid", cb))).rejects.toThrow("db down");
expect(client.release).toHaveBeenCalledTimes(1);
});
it("surfaces the error and calls back exactly once when acquiring a client fails", async () => {
getPool.mockImplementationOnce(async () => { throw new Error("pool exhausted"); });
const store = makeStore({ memoryTtlMs: 0 });
const cb = jest.fn();
await new Promise((resolve) => store.get("sid", (...args) => { cb(...args); resolve(); }));
expect(cb).toHaveBeenCalledTimes(1);
expect(cb.mock.calls[0][0]).toBeInstanceOf(Error);
expect(cb.mock.calls[0][0].message).toBe("pool exhausted");
});
});

View File

@@ -0,0 +1,21 @@
<!doctype html>
<html lang="en">
<%- include('./include/header.ejs', { title: 'JLINC - MyTerms Agreement' }) %>
<style>
a {
color: #31A9BA !important
}
</style>
<div class="mdc-card" style="background: linear-gradient(333deg, rgb(0, 0, 0) 0%, rgb(79, 55, 139) 100%);">
<%- agreement %>
</div>
<br>
View the <a href="<%- rawUrl %>">raw agreement content</a>.
<%- include('./include/footer.ejs') %>

View File

@@ -0,0 +1,22 @@
<!doctype html>
<html lang="en">
<%- include('./include/header.ejs', { title: 'JLINC - Dashboard' }) %>
<% for (const type in config.appModules) { %>
<% if (config.appModules[type].internal) { continue; } %>
<%-
include('./include/app.ejs', {
app: config.appModules[type],
usage: usage ? usage[type] : null
})
%>
<% } %>
<%-
include('./include/agreements.ejs', {
app: config.appModules['core']
})
%>
<%- include('./include/footer.ejs') %>

View File

@@ -0,0 +1,26 @@
<% const cardStyle=`"background: linear-gradient(333deg, ${app.background.color1} 0%, ${app.background.color2} 100%);
margin-bottom: 30px;"`; const userApp=user.apps.find(ua=> app.type === 'core');
const buttonStyle = `"padding: 8px 10px 8px 10px; border-radius: 16px !important; background-color:
${app.button.color} !important; border: none !important;"`
%>
<div class="mdc-card mdc-theme--dark" style=<%- cardStyle %>>
<div style="display: flex; align-items: center;">
<div style="width: 30px">
<%- app.logo %>
</div>
<h2 style="margin-left: 10px; padding-bottom: 10px;">
Available Agreements
</h2>
</div>
<div style="display: flex; align-items: center;">
<ul style="margin-top: 0px">
<% for (const agreement of agreements) { %>
<li style="padding-bottom: 1em"><a target="_new" class="agreement-link" href="/agreements/<%- agreement.hash %>"><%- agreement.title %></a></li>
<% } %>
</ul>
</div>
</div>

View File

@@ -0,0 +1,163 @@
<% const cardStyle=`"background: linear-gradient(333deg, ${app.background.color1} 0%, ${app.background.color2} 100%);
margin-bottom: 30px;"`; const userApp=user.apps.find(ua=> app.type === ua.type);
const buttonStyle = `"padding: 8px 10px 8px 10px; border-radius: 16px !important; background-color:
${app.button.color} !important; border: none !important;"`
%>
<script>
function copyToClipboard(label, text) {
if (window.clipboardData && window.clipboardData.setData) {
// Internet Explorer specific code path to prevent textarea being shown while dialog is visible.
return clipboardData.setData('Text', text);
} else if (document.queryCommandSupported && document.queryCommandSupported('copy')) {
var textarea = document.createElement("textarea");
textarea.textContent = text;
// Prevent scrolling to bottom of page in Microsoft Edge.
textarea.style.position = 'fixed';
document.body.appendChild(textarea);
textarea.select();
const flash = document.createElement('div');
flash.style.position = 'fixed';
flash.style.top = '0';
flash.style.left = '0';
flash.style.width = '100%';
flash.style.backgroundColor = '#000';
flash.style.color = '#ggg';
flash.style.opacity = '0.7';
flash.style.textAlign = 'center';
flash.style.fontSize = '12px';
flash.style.padding = '10px';
try {
const cmd = document.execCommand('copy'); // Security exception may be thrown by some browsers.
flash.innerHTML = `${label} copied to clipboard`;
document.body.appendChild(flash);
setTimeout(function () {
document.body.removeChild(flash);
}, 1500);
return cmd;
} catch (ex) {
flash.style.backgroundColor = '#f00';
flash.innerHTML = 'API key copy failed';
document.body.appendChild(flash);
setTimeout(function () {
document.body.removeChild(flash);
}, 1500);
return false;
} finally {
document.body.removeChild(textarea);
}
}
}
</script>
<div class="mdc-card mdc-theme--dark" style=<%- cardStyle %>>
<div style="display: flex; align-items: center;">
<div style="width: 30px">
<%- app.logo %>
</div>
<h2 style="margin-left: 10px; padding-bottom: 10px;">
<%= app.title %>
</h2>
</div>
<label class="mdc-text-field mdc-text-field--outlined mdc-text-field--focused">
<span class="mdc-notched-outline" style="--mdc-theme-primary: rgba(255, 255, 255, 0.3)">
<span class="mdc-notched-outline__leading"></span>
<span class="mdc-notched-outline__trailing"></span>
</span>
<input style="color: #fff; text-overflow: ellipsis;" type="text" id="endpoint-input"
aria-describedby="api-key-helper" class="mdc-text-field__input" disabled type="text"
value="<%= app.endpoint %>">
<i style="color: #fff" class="material-icons mdc-text-field__icon mdc-text-field__icon--trailing"
tabindex="0" role="button" onclick="copyToClipboard('API Endpoint', '<%= app.endpoint %>')">
content_copy
</i>
</label>
<div class="mdc-text-field-helper-line" style="padding-bottom: 20px">
<div style="color: #fff" class="mdc-text-field-helper-text" id="endpoint-helper" aria-hidden="false">API
Endpoint
</div>
</div>
<label class="mdc-text-field mdc-text-field--outlined mdc-text-field--focused">
<span class="mdc-notched-outline" style="--mdc-theme-primary: rgba(255, 255, 255, 0.3)">
<span class="mdc-notched-outline__leading"></span>
<span class="mdc-notched-outline__trailing"></span>
</span>
<input style="color: #fff; text-overflow: ellipsis;" type="text" id="api-key-input"
aria-describedby="api-key-helper" class="mdc-text-field__input" disabled type="text"
value="<%= userApp.apiKey %>">
<i style="color: #fff" class="material-icons mdc-text-field__icon mdc-text-field__icon--trailing"
tabindex="0" role="button" onclick="copyToClipboard('API Key', '<%= userApp.apiKey %>')">
content_copy
</i>
</label>
<div class="mdc-text-field-helper-line">
<div style="color: #fff" class="mdc-text-field-helper-text" id="api-key-helper" aria-hidden="false">API Key
</div>
</div>
<div style="display: flex; justify-content: flex-end;">
<button style="width: 140px; --mdc-theme-primary: <%= app.button.color %>"
class="mdc-button mdc-button--raised mdc-button--leading"
onclick="window.location.href='/refresh?app=<%= app.type %>'">
<span class="mdc-button__ripple"></span>
<i class="material-icons mdc-button__icon" aria-hidden="true">refresh</i>
<span class="mdc-button__label">Refresh</span>
</button>
</div>
<% if (usage) { %>
<div style="display: flex; align-items: center;">
<h3 style="margin: 0; display: flex; align-items: center; margin-right: 16px;">Hits this month:</h3>
<span class="mdc-evolution-chip-set" role="grid">
<span class="mdc-evolution-chip-set__chips" role="presentation">
<span class="mdc-evolution-chip" role="row">
<span class="mdc-evolution-chip__cell mdc-evolution-chip__cell--primary" role="gridcell">
<button style=<%- buttonStyle %>
class="mdc-evolution-chip__action mdc-evolution-chip__action--primary" type="button"
tabindex="0">
<span class="mdc-evolution-chip__ripple mdc-evolution-chip__ripple--primary"></span>
<span class="mdc-evolution-chip__text-label">
<%= usage %>
</span>
</button>
</span>
</span>
</span>
</span>
</div>
<% } %>
<% if (userApp.devices?.length> 0) { %>
<h3 style="margin-bottom: 0px">Synced devices</h3>
<span class="mdc-evolution-chip-set" role="grid" style="padding-top: 16px">
<span class="mdc-evolution-chip-set__chips" role="presentation">
<% for (const device of userApp.devices.sort((a, b)=>
a.identifier.localeCompare(b.identifier))) {
%>
<span style="padding-right: 10px" class="mdc-evolution-chip" role="row"
id="device-<%- device.id %>">
<span class="mdc-evolution-chip__cell mdc-evolution-chip__cell--primary"
role="gridcell">
<button style=<%- buttonStyle %> class="mdc-evolution-chip__action
mdc-evolution-chip__action--primary"
type="button" tabindex="0">
<span
class="mdc-evolution-chip__ripple mdc-evolution-chip__ripple--primary"></span>
<span class="mdc-evolution-chip__text-label">
<%= device.identifier %>
</span>
</button>
</span>
</span>
<% } %>
</span>
</span>
<% } %>
</div>

View File

@@ -0,0 +1,6 @@
</div>
</center>
</div>
</body>
</html>

View File

@@ -0,0 +1,125 @@
<head>
<title>
<%= title %>
</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" href="/images/icon.svg" type="image/x-icon">
<link rel="stylesheet" href="https://unpkg.com/material-components-web@latest/dist/material-components-web.min.css">
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons">
<link href="https://fonts.googleapis.com/css2?family=Open+Sans:wght@400;500&display=swap" rel="stylesheet">
<script src="https://unpkg.com/material-components-web@latest/dist/material-components-web.min.js"></script>
<style>
:root {
--md-ref-typeface-brand: 'Open Sans', sans-serif;
--md-ref-typeface-plain: system-ui, sans-serif;
}
body {
font-family: var(--md-ref-typeface-plain);
color: #fff;
background-color: rgba(0, 0, 0, 0);
background-image: linear-gradient(135deg, #31A9BA 10%, #0E0618 10%, #231641 90%, #31A9BA 90%);
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
margin: 0;
}
div {
font-family: var(--md-ref-typeface-plain);
}
h1,
h2,
h3 {
font-family: var(--md-ref-typeface-brand);
}
h1 {
font-weight: 400;
}
h2 {
font-weight: 500;
}
h3 {
font-weight: 500;
}
.mdc-card {
padding-bottom: 16px;
padding-left: 16px;
padding-right: 16px;
background-color: rgb(68, 62, 77);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
}
.mdc-button {
--mdc-theme-primary: rgb(255, 255, 255, .85);
--mdc-theme-on-primary: rgb(68, 62, 77);
/* @include button.ink-color(#84565E); */
}
.agreement-link {
font-family: var(--md-ref-typeface-plain);
color: white;
}
</style>
</head>
<body>
<div class="profile-icon-container" style="position: absolute; top: 16px; right: 16px;">
<% if (typeof user !=='undefined' && user) { %>
<div style="position: relative;">
<button id="profileButton" class="profile-icon"
style="border: none; border-radius: 50%; width: 48px; height: 48px; display: flex; justify-content: center; align-items: center; background-color: #31A9BA; color: #fff; cursor: pointer;">
<span style="font-size: 30px">
<%= user.username.charAt(0).toUpperCase() %>
</span>
</button>
<div id="profileMenu"
style="display: none; position: absolute; top: 60px; right: 0; background-color: #4F378B; box-shadow: 0 2px 8px rgba(0,0,0,0.15); border-radius: 8px; padding: 10px; min-width: 160px; z-index: 100;">
<div style="padding: 8px; font-weight: bold;">
<span style="display: block; max-width: 100%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">
<%= user.username %>
</span>
</div>
<form method="POST" action="/logout">
<button type="submit"
style="width: 100%; padding: 8px; background-color: #31A9BA; color: white; border: none; border-radius: 4px; cursor: pointer;">Logout</button>
</form>
</div>
</div>
<script>
const profileButton = document.getElementById('profileButton');
const profileMenu = document.getElementById('profileMenu');
profileButton.addEventListener('click', () => {
profileMenu.style.display = profileMenu.style.display === 'block' ? 'none' : 'block';
});
// Optional: Hide menu if clicking outside
document.addEventListener('click', (event) => {
if (!profileButton.contains(event.target) && !profileMenu.contains(event.target)) {
profileMenu.style.display = 'none';
}
});
</script>
<% } %>
</div>
<div style="width: 100%; overflow-y: auto; max-height: 100%; word-wrap: break-word;">
<center>
<div style="width: 90%; max-width: 600px; text-align: left">
<div
style="padding-top: 20px; display: flex; align-items: center; justify-content: center; gap: 0px; text-align: center; transform: translateX(-0px);">
<div style="width: 200px; padding-bottom: 26px">
<%- include('./logo-white.svg') %>
</div>
</div>

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 362.08 100.47"><defs><style>.cls-1{fill:#fff;}.cls-2{fill:#31a9ba;}</style></defs><title>JLINC Logo H White</title><g id="Layer_2" data-name="Layer 2"><g id="Layer_1-2" data-name="Layer 1"><path class="cls-1" d="M157.32,29.36h8.35V57.2q0,13.9-13.92,13.91H137.84q-14.22,0-14-13.91l0-1.4h8.35v.25q0,6.72,6.68,6.71h11.66q6.7,0,6.71-6.73Z"/><path class="cls-1" d="M223.12,62.76v8.35H181.37V29.36h8.35v33.4Z"/><path class="cls-1" d="M247.18,71.11h-8.35V29.36h8.35Z"/><path class="cls-1" d="M271.23,42V71.11h-8.35V29.36h8.51l24.89,29.09V29.36h8.35V71.11h-8.49Z"/><path class="cls-1" d="M362.08,62.76v8.35H334.25q-13.92,0-13.92-13.91V43.28q0-13.92,13.92-13.92h27.83v8.35H335.39q-6.7,0-6.71,6.68V56.05q0,6.72,6.74,6.71Z"/><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>

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@@ -0,0 +1,23 @@
<!doctype html>
<html lang="en">
<%- include('./include/header.ejs', { title: 'JLINC - Login' }) %>
<div class="mdc-card" style="background: linear-gradient(333deg, rgb(0, 0, 0) 0%, rgb(79, 55, 139) 100%);">
<h3>Login with:</h3>
<% for (const type in config.authModules) { %>
<% if (type !== 'single') { %>
<div style="width: 100%; padding-bottom: 16px">
<button style="width: 100%" class="mdc-button mdc-button--raised mdc-button--leading" onclick="window.location.href='/login/<%= type %>'">
<span class="mdc-button__ripple"></span>
<i class="material-icons mdc-button__icon" aria-hidden="true"><%= config.authModules[type].icon %></i>
<span class="mdc-button__label"><%= config.authModules[type].title %></span>
</button>
</div>
<% } %>
<% } %>
</div>
<%- include('./include/footer.ejs') %>

27
backend/index.js Normal file
View File

@@ -0,0 +1,27 @@
import express from "express";
import { init, close, migrate, populateAgreements } from "./db/index.js";
import { loadConfig } from "./common/config.js";
import { loadApps } from "./apps.js";
import { initHTTP } from "./http/index.js";
import { migrateLegacyApiKeys } from "./modules/core/apiKey.js";
const app = express();
app.set("env", "development");
app.use(express.static("./public"));
async function main() {
await loadConfig();
await init();
await migrate();
await populateAgreements();
await loadApps();
await initHTTP(app);
// After initHTTP so the auth rows (incl. the single/Docker user's) exist.
await migrateLegacyApiKeys();
const server = await app.listen(9090, () => {
console.log(`Listening on 0.0.0.0:9090`);
});
server.on("beforeExit", close);
}
main();

8
backend/jest.config.js Normal file
View File

@@ -0,0 +1,8 @@
// Native ESM: tests run under Node's experimental VM modules, no Babel.
// packages/ is excluded — @jlinc/core ships its own suite.
export default {
testEnvironment: "node",
transform: {},
testPathIgnorePatterns: ["/node_modules/", "/packages/"],
clearMocks: true,
};

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

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

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

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Some files were not shown because too many files have changed in this diff Show More