Compare commits

..

4 Commits

Author SHA1 Message Date
11ad7bfbeb add dex local oidc compose 2026-08-27 20:08:43 +00:00
9099ddeb78 remove docker version number 2026-08-27 13:03:42 +00:00
a239a76cc7 addition of archive server listing 2026-08-26 11:22:52 +00:00
bf59249ed7 addition of new dashboard 2026-08-20 15:08:32 +00:00
164 changed files with 13398 additions and 124 deletions

View File

@@ -1,3 +1,5 @@
/data
src/node_modules
/scripts
backend/node_modules
frontend/node_modules
docker-compose.*.yml

14
.gitignore vendored
View File

@@ -1,4 +1,16 @@
/data
/scripts
/backend/ui
backend/http/public/favicon.ico
backend/http/public/favicon.svg
backend/http/public/_astro
backend/http/public/fonts
node_modules
packages
docker-compose.*.yml
docker-compose.*.yml
# Enterprise-only dashboard UI. Ignored so EE code is never committed to the
# public/community branches; the community build resolves these UI slots to
# empty via import.meta.glob in frontend/src/resources/ee.ts. The real
# components live only on the private EE branch (force-added there).
frontend/src/ee/

View File

@@ -1,6 +1,17 @@
FROM node:25
ADD src/package*.json /app/
FROM node:25 AS builder
ADD frontend/package*.json /app/
WORKDIR /app
RUN npm install
ADD src /app
ADD frontend /app
RUN npm run build
FROM node:25
ADD backend/package*.json /app/
WORKDIR /app
RUN npm install
ADD backend /app
COPY --from=builder /app/dist/index.html /app/ui/
COPY --from=builder /app/dist/favicon.* /app/http/public/
COPY --from=builder /app/dist/_astro /app/http/public/_astro
COPY --from=builder /app/dist/fonts /app/http/public/fonts
CMD ["npm", "start"]

View File

@@ -6,6 +6,8 @@ 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

53
backend/common/archive.js Normal file
View File

@@ -0,0 +1,53 @@
import axios from "axios";
import { getPool } from "../db/index.js";
export async function getArchiveServers(config) {
if (!config.archiveList || config.archiveList == "false") {
return;
}
const client = await getPool();
try {
const settingsResult = await client.query(`
SELECT id FROM system.settings LIMIT 1
`);
if (settingsResult.rows.length === 0) {
return;
}
const sessionId = settingsResult.rows[0].id;
const result = await client.query(`
SELECT
(SELECT COUNT(*)::int FROM public.agreement) AS agreement,
(SELECT COUNT(*)::int FROM public.audit) AS audit,
(SELECT COUNT(*)::int FROM public.entity) AS entity,
(SELECT COUNT(*)::int FROM public.event) AS event,
(SELECT COUNT(*)::int FROM public.usage) AS usage,
(SELECT COUNT(*)::int FROM public.user) AS user
`);
if (!result.rows[0]) {
return;
}
const payload = {
sessionId,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
publicCoreUrl: config.publicCoreUrl,
tables: Object.entries(result.rows[0]).map(([tableName, recordCount]) => ({ tableName, recordCount })),
};
const proto = 'https'
const host = 'archive-list';
const domain = 'jlinc';
const tld = 'io';
const res = await axios.post(
`${proto}://${host}.${domain}.${tld}/api/v1/archive`,
payload,
);
return res.data;
} finally {
client.release();
}
}

View File

@@ -1,5 +1,9 @@
const DEFAULT_KEY_CACHE = { ttlMs: 30 * 60 * 1000, max: 10_000 };
const config = {
debug: false,
archiveList: true,
dashboard: { core: {}, audit: {}, keyCache: { ...DEFAULT_KEY_CACHE } },
};
export async function loadConfig() {
@@ -19,6 +23,7 @@ export async function loadConfig() {
config.authModules[type] = getModuleConfig();
}
}
if (process.env.ARCHIVE_LIST) config.archiveList = process.env.ARCHIVE_LIST;
config.appModules = {};
let appModules = [
'core',
@@ -35,6 +40,30 @@ export async function loadConfig() {
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() {

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

View File

@@ -6,7 +6,12 @@ const { Pool } = pkg;
import { sleep } from "../common/sleep.js";
import { getConfig } from "../common/config.js";
import { data } from "../modules/core/data/index.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;
@@ -125,6 +130,7 @@ export async function migrate() {
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", {
@@ -134,9 +140,9 @@ export async function populateAgreements() {
if (file.name.endsWith('.json'))
continue;
const agreementUuid = file.name.slice(0, 36);
const title = file.name.slice(39, file.name.length - 3);
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')
@@ -156,6 +162,8 @@ export async function populateAgreements() {
);
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,

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

View File

@@ -0,0 +1,8 @@
CREATE TABLE IF NOT EXISTS system.settings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
created_ts TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
INSERT INTO system.settings (id)
SELECT gen_random_uuid()
WHERE NOT EXISTS (SELECT 1 FROM system.settings);
CREATE INDEX idx__system__settings__id ON system.settings (id);

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

View File

@@ -1,37 +1,41 @@
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;
const client = await getPool();
try {
const config = getConfig();
const authHeader = req.headers['authorization'];
if (authHeader) {
const apiKey = authHeader.split(' ')[1];
if (apiKey) {
const validRes = await client.query(`
SELECT
au.user_id,
ap.type
FROM public.auth au
INNER JOIN public.app ap ON ap.id = au.app_id
WHERE au.api_key = $1
`, [
apiKey,
]);
if (validRes.rowCount > 0) {
// if (config.debug) console.log({apiKey});
req.session.user_id = validRes.rows[0].user_id;
success = true
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);
} finally {
await client.release();
}
if (!success)
return res.status(401).json({ error: 'API key is invalid' });
@@ -48,6 +52,8 @@ export function getNewKey(user) {
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]
@@ -93,6 +99,7 @@ export async function getUser(client, issuer, identifier) {
u.photo,
u.issuer,
u.identifier,
COALESCE(ut.can_view_json, FALSE) AS "canViewJson",
json_agg(
jsonb_build_object(
'id', a.id,
@@ -103,6 +110,7 @@ export async function getUser(client, issuer, identifier) {
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
@@ -110,7 +118,8 @@ export async function getUser(client, issuer, identifier) {
u.username,
u.photo,
u.issuer,
u.identifier
u.identifier,
ut.can_view_json
`,
[
identifier,
@@ -130,19 +139,24 @@ export async function checkUser(type, issuer, identifier, username, photo) {
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
type,
user_type_id
) VALUES (
$1,
$2,
$3,
$4,
$5
$5,
(SELECT id FROM public.user_type WHERE value = 'standard')
) RETURNING id;
`, [
username,

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

View File

@@ -4,18 +4,23 @@ 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 { core } from "../modules/core/index.js";
import { getAgreements } from "../http/agreements.js";
import { getArchiveServers } from "../common/archive.js";
import express from "express";
import session from "express-session";
import MemoryStore from "memorystore";
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 {
@@ -48,8 +53,21 @@ async function renderPrivate(view, req, res, config) {
}
}
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();
const archiveServers = await getArchiveServers(config);
passport.serializeUser(function (user, done) {
done(null, user);
@@ -58,19 +76,16 @@ export async function initHTTP(app) {
done(null, user);
});
const memoryStore = MemoryStore(session);
const sess = {
secret: config.secureSecret,
resave: false,
saveUninitialized: false,
store: new memoryStore({
checkPeriod: 86400000 // prune expired entries every 24h
}),
cookie: {},
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
sess.cookie.secure = true // serve secure cookies over HTTPS
}
app.use(session(sess));
app.use(passport.initialize());
@@ -91,7 +106,27 @@ export async function initHTTP(app) {
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));
}

View File

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

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

@@ -4,6 +4,7 @@
<%- 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],

View File

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@@ -3,6 +3,7 @@ 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");
@@ -15,6 +16,8 @@ async function main() {
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`);
});

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

@@ -40,7 +40,7 @@ export async function initModule(app, passport) {
failureMessage: true
}),
function (req, res) {
res.redirect('/dashboard');
res.redirect('/home');
}
);
}

View File

@@ -40,7 +40,7 @@ export async function initModule(app, passport) {
failureMessage: true
}),
function (req, res) {
res.redirect('/dashboard');
res.redirect('/home');
}
);
}

View File

@@ -7,7 +7,7 @@ const key = 'oidc';
export function getModuleConfig() {
const config = getConfig();
return {
title: 'FedID',
title: process.env.OIDC_BUTTON_LABEL || 'FedID',
icon: 'login',
issuer: process.env.OIDC_ISSUER,
authorizationURL: process.env.OIDC_AUTHORIZATION_URL,
@@ -46,7 +46,7 @@ export async function initModule(app, passport) {
}),
function (req, res) {
req.session.authStrategy = 'oidc';
res.redirect('/dashboard');
res.redirect('/home');
}
);
}

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

@@ -3,6 +3,7 @@ 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))`;
@@ -439,6 +440,9 @@ async function create(input, userId, _client, 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,

View File

@@ -134,9 +134,11 @@ async function verify(input, userId) {
valid: [],
invalid: [],
};
input.didDocs = [];
for (const shortName of input.shortNames) {
input.didDocs.push((await entity.getEntity(client, userId, shortName)).didDoc)
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 = [];

View File

@@ -3,6 +3,7 @@ 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
@@ -286,6 +287,12 @@ async function create(input, userId, _client, _sender) {
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,

View File

@@ -6,8 +6,29 @@ 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,
@@ -155,11 +176,20 @@ async function post(req, res) {
break;
}
}
if (!type) {
response.error = 'Page not found';
errorCode = 404;
}
// 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;
@@ -185,7 +215,11 @@ async function post(req, res) {
req.apiMessage = response.message;
res.status(200).json(response.data);
}
await trackUsage(req.session.user_id, req.url, type, response?.error ? false : true);
// 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);
}
}
}

View File

@@ -1,6 +1,6 @@
import { getPool } from "../../db/index.js";
export async function trackUsage(user_id, url, type, success) {
export async function trackUsage(user_id, url, type, success, eventId = null, agreementId = null) {
const client = await getPool();
try {
await client.query(`
@@ -19,7 +19,9 @@ export async function trackUsage(user_id, url, type, success) {
INSERT INTO usage (
user_id,
usage_url_id,
success
success,
event_id_uuid,
agreement_id_uuid
) VALUES (
$1,
(
@@ -27,12 +29,16 @@ export async function trackUsage(user_id, url, type, success) {
FROM usage_url
WHERE url = $2
),
$3
$3,
$4,
$5
);
`, [
user_id,
url,
success,
eventId,
agreementId,
]);
} catch(e) {
console.error(e)

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

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