Compare commits
6 Commits
0622018d95
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| edcfc8c77f | |||
| 997438035b | |||
| 11ad7bfbeb | |||
| 9099ddeb78 | |||
| a239a76cc7 | |||
| bf59249ed7 |
@@ -1,3 +1,5 @@
|
|||||||
/data
|
/data
|
||||||
src/node_modules
|
/scripts
|
||||||
|
backend/node_modules
|
||||||
|
frontend/node_modules
|
||||||
docker-compose.*.yml
|
docker-compose.*.yml
|
||||||
|
|||||||
12
.gitignore
vendored
12
.gitignore
vendored
@@ -1,4 +1,16 @@
|
|||||||
/data
|
/data
|
||||||
|
/scripts
|
||||||
|
/backend/ui
|
||||||
|
backend/http/public/favicon.ico
|
||||||
|
backend/http/public/favicon.svg
|
||||||
|
backend/http/public/_astro
|
||||||
|
backend/http/public/fonts
|
||||||
node_modules
|
node_modules
|
||||||
packages
|
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/
|
||||||
17
Dockerfile
17
Dockerfile
@@ -1,6 +1,17 @@
|
|||||||
FROM node:25
|
FROM node:25 AS builder
|
||||||
ADD src/package*.json /app/
|
ADD frontend/package*.json /app/
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
RUN npm install
|
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"]
|
CMD ["npm", "start"]
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ export async function loadApps() {
|
|||||||
const config = getConfig();
|
const config = getConfig();
|
||||||
const client = await getPool();
|
const client = await getPool();
|
||||||
for (const type in config.appModules) {
|
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(`
|
await client.query(`
|
||||||
INSERT INTO public.app (
|
INSERT INTO public.app (
|
||||||
type
|
type
|
||||||
53
backend/common/archive.js
Normal file
53
backend/common/archive.js
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
|
const DEFAULT_KEY_CACHE = { ttlMs: 30 * 60 * 1000, max: 10_000 };
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
debug: false,
|
debug: false,
|
||||||
|
archiveList: true,
|
||||||
|
dashboard: { core: {}, audit: {}, keyCache: { ...DEFAULT_KEY_CACHE } },
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function loadConfig() {
|
export async function loadConfig() {
|
||||||
@@ -19,6 +23,7 @@ export async function loadConfig() {
|
|||||||
config.authModules[type] = getModuleConfig();
|
config.authModules[type] = getModuleConfig();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (process.env.ARCHIVE_LIST) config.archiveList = process.env.ARCHIVE_LIST;
|
||||||
config.appModules = {};
|
config.appModules = {};
|
||||||
let appModules = [
|
let appModules = [
|
||||||
'core',
|
'core',
|
||||||
@@ -35,6 +40,30 @@ export async function loadConfig() {
|
|||||||
if (process.env.PDP_TYPE) config.pdpType = process.env.PDP_TYPE;
|
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.PDP_URL) config.pdpUrl = process.env.PDP_URL;
|
||||||
if (process.env.INSTANT_QUEUE) config.instantQueue = process.env.INSTANT_QUEUE;
|
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() {
|
export function getConfig() {
|
||||||
14
backend/common/http.js
Normal file
14
backend/common/http.js
Normal 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");
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -6,7 +6,12 @@ const { Pool } = pkg;
|
|||||||
|
|
||||||
import { sleep } from "../common/sleep.js";
|
import { sleep } from "../common/sleep.js";
|
||||||
import { getConfig } from "../common/config.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;
|
let pool;
|
||||||
|
|
||||||
@@ -125,6 +130,7 @@ export async function migrate() {
|
|||||||
export async function populateAgreements() {
|
export async function populateAgreements() {
|
||||||
console.log(`Starting agreement population`);
|
console.log(`Starting agreement population`);
|
||||||
const config = getConfig();
|
const config = getConfig();
|
||||||
|
const { data } = await import("../modules/core/data/index.js");
|
||||||
const client = await pool.connect();
|
const client = await pool.connect();
|
||||||
try {
|
try {
|
||||||
const files = fs.readdirSync("./db/agreements", {
|
const files = fs.readdirSync("./db/agreements", {
|
||||||
@@ -134,9 +140,9 @@ export async function populateAgreements() {
|
|||||||
if (file.name.endsWith('.json'))
|
if (file.name.endsWith('.json'))
|
||||||
continue;
|
continue;
|
||||||
const agreementUuid = file.name.slice(0, 36);
|
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 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 json = JSON.parse(fs.readFileSync(path.join("./db/agreements", file.name.replace('.md', '.json')), "utf8").trim());
|
||||||
|
const title = firstNonBlankLine(markdown);
|
||||||
const hash = createHash('sha256')
|
const hash = createHash('sha256')
|
||||||
.update(markdown)
|
.update(markdown)
|
||||||
.digest('hex')
|
.digest('hex')
|
||||||
@@ -156,6 +162,8 @@ export async function populateAgreements() {
|
|||||||
);
|
);
|
||||||
if (!agreementExists.rows[0].exists) {
|
if (!agreementExists.rows[0].exists) {
|
||||||
console.log(`Adding agreement '${title}' (${agreementUuid})`);
|
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(`
|
await client.query(`
|
||||||
INSERT INTO agreement_content (
|
INSERT INTO agreement_content (
|
||||||
title,
|
title,
|
||||||
11
backend/db/migrations/000015 - session.sql
Normal file
11
backend/db/migrations/000015 - session.sql
Normal 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);
|
||||||
14
backend/db/migrations/000016 - cache.sql
Normal file
14
backend/db/migrations/000016 - cache.sql
Normal 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);
|
||||||
21
backend/db/migrations/000017 - api-key.sql
Normal file
21
backend/db/migrations/000017 - api-key.sql
Normal 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);
|
||||||
6
backend/db/migrations/000018 - usage-event-agreement.sql
Normal file
6
backend/db/migrations/000018 - usage-event-agreement.sql
Normal 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);
|
||||||
30
backend/db/migrations/000019 - user-type.sql
Normal file
30
backend/db/migrations/000019 - user-type.sql
Normal 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;
|
||||||
2
backend/db/migrations/000020 - add-remote-url.sql
Normal file
2
backend/db/migrations/000020 - add-remote-url.sql
Normal 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);
|
||||||
15
backend/db/migrations/000021 - agreement-fetch.sql
Normal file
15
backend/db/migrations/000021 - agreement-fetch.sql
Normal 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);
|
||||||
8
backend/db/migrations/000022 - system-identity.sql
Normal file
8
backend/db/migrations/000022 - system-identity.sql
Normal 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
50
backend/http/apiKeys.js
Normal 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 });
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
95
backend/http/apiKeys.test.js
Normal file
95
backend/http/apiKeys.test.js
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,37 +1,41 @@
|
|||||||
import { getConfig } from "../common/config.js";
|
import { getConfig } from "../common/config.js";
|
||||||
import { getPool } from "../db/index.js";
|
import { getPool } from "../db/index.js";
|
||||||
|
import { verifyKey } from "../modules/core/apiKey.js";
|
||||||
import crypto from 'crypto';
|
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) {
|
export async function apiMiddleware(req, res, next) {
|
||||||
let success = false;
|
let success = false;
|
||||||
|
try {
|
||||||
|
const apiKey = req.headers['authorization']?.split(' ')[1];
|
||||||
|
if (apiKey) {
|
||||||
|
// Preferred: the hashed multi-key store (with a short in-memory cache).
|
||||||
|
const found = await verifyKey(apiKey);
|
||||||
|
if (found) {
|
||||||
|
req.session.user_id = found.user_id;
|
||||||
|
success = true;
|
||||||
|
} else {
|
||||||
|
// Fallback: the legacy single plaintext key in `auth` (issued at login).
|
||||||
const client = await getPool();
|
const client = await getPool();
|
||||||
try {
|
try {
|
||||||
const config = getConfig();
|
const r = await client.query(
|
||||||
const authHeader = req.headers['authorization'];
|
`SELECT au.user_id FROM public.auth au WHERE au.api_key = $1`,
|
||||||
if (authHeader) {
|
[apiKey],
|
||||||
const apiKey = authHeader.split(' ')[1];
|
);
|
||||||
if (apiKey) {
|
if (r.rowCount > 0) {
|
||||||
const validRes = await client.query(`
|
req.session.user_id = r.rows[0].user_id;
|
||||||
SELECT
|
success = true;
|
||||||
au.user_id,
|
}
|
||||||
ap.type
|
} finally {
|
||||||
FROM public.auth au
|
await client.release();
|
||||||
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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
} finally {
|
|
||||||
await client.release();
|
|
||||||
}
|
}
|
||||||
if (!success)
|
if (!success)
|
||||||
return res.status(401).json({ error: 'API key is invalid' });
|
return res.status(401).json({ error: 'API key is invalid' });
|
||||||
@@ -48,6 +52,8 @@ export function getNewKey(user) {
|
|||||||
async function createApiKeys(client, user, issuer) {
|
async function createApiKeys(client, user, issuer) {
|
||||||
const config = getConfig();
|
const config = getConfig();
|
||||||
for (const type in config.appModules) {
|
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'
|
const apiKey = issuer !== 'https://single'
|
||||||
? getNewKey(user)
|
? getNewKey(user)
|
||||||
: config.authModules.single[type]
|
: config.authModules.single[type]
|
||||||
@@ -93,6 +99,7 @@ export async function getUser(client, issuer, identifier) {
|
|||||||
u.photo,
|
u.photo,
|
||||||
u.issuer,
|
u.issuer,
|
||||||
u.identifier,
|
u.identifier,
|
||||||
|
COALESCE(ut.can_view_json, FALSE) AS "canViewJson",
|
||||||
json_agg(
|
json_agg(
|
||||||
jsonb_build_object(
|
jsonb_build_object(
|
||||||
'id', a.id,
|
'id', a.id,
|
||||||
@@ -103,6 +110,7 @@ export async function getUser(client, issuer, identifier) {
|
|||||||
FROM public.user u
|
FROM public.user u
|
||||||
INNER JOIN public.auth au ON u.id = au.user_id
|
INNER JOIN public.auth au ON u.id = au.user_id
|
||||||
INNER JOIN public.app a ON au.app_id = a.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
|
WHERE u.identifier = $1
|
||||||
AND u.issuer = $2
|
AND u.issuer = $2
|
||||||
GROUP BY
|
GROUP BY
|
||||||
@@ -110,7 +118,8 @@ export async function getUser(client, issuer, identifier) {
|
|||||||
u.username,
|
u.username,
|
||||||
u.photo,
|
u.photo,
|
||||||
u.issuer,
|
u.issuer,
|
||||||
u.identifier
|
u.identifier,
|
||||||
|
ut.can_view_json
|
||||||
`,
|
`,
|
||||||
[
|
[
|
||||||
identifier,
|
identifier,
|
||||||
@@ -130,19 +139,24 @@ export async function checkUser(type, issuer, identifier, username, photo) {
|
|||||||
if (!username || username === '') {
|
if (!username || username === '') {
|
||||||
username = identifier;
|
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(`
|
const res = await client.query(`
|
||||||
INSERT INTO public.user (
|
INSERT INTO public.user (
|
||||||
username,
|
username,
|
||||||
issuer,
|
issuer,
|
||||||
identifier,
|
identifier,
|
||||||
photo,
|
photo,
|
||||||
type
|
type,
|
||||||
|
user_type_id
|
||||||
) VALUES (
|
) VALUES (
|
||||||
$1,
|
$1,
|
||||||
$2,
|
$2,
|
||||||
$3,
|
$3,
|
||||||
$4,
|
$4,
|
||||||
$5
|
$5,
|
||||||
|
(SELECT id FROM public.user_type WHERE value = 'standard')
|
||||||
) RETURNING id;
|
) RETURNING id;
|
||||||
`, [
|
`, [
|
||||||
username,
|
username,
|
||||||
61
backend/http/auth.test.js
Normal file
61
backend/http/auth.test.js
Normal 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
34
backend/http/dashboard.js
Normal 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));
|
||||||
|
})
|
||||||
|
};
|
||||||
|
}
|
||||||
169
backend/http/dashboard.test.js
Normal file
169
backend/http/dashboard.test.js
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
100
backend/http/dashboardPassthrough.js
Normal file
100
backend/http/dashboardPassthrough.js
Normal 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))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
97
backend/http/dashboardPassthrough.test.js
Normal file
97
backend/http/dashboardPassthrough.test.js
Normal 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 }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,18 +4,23 @@ import { loadPep } from "../modules/pep/index.js";
|
|||||||
import swaggerUi from "swagger-ui-express";
|
import swaggerUi from "swagger-ui-express";
|
||||||
import swaggerDocument from "./api/v1/swagger.json" with { type: "json" };
|
import swaggerDocument from "./api/v1/swagger.json" with { type: "json" };
|
||||||
import { getConfig } from "../common/config.js";
|
import { getConfig } from "../common/config.js";
|
||||||
import { core } from "../modules/core/index.js"
|
import { core } from "../modules/core/index.js";
|
||||||
import { getAgreements } from "../http/agreements.js"
|
import { getAgreements } from "../http/agreements.js";
|
||||||
|
import { getArchiveServers } from "../common/archive.js";
|
||||||
|
|
||||||
import express from "express";
|
import express from "express";
|
||||||
import session from "express-session";
|
import session from "express-session";
|
||||||
import MemoryStore from "memorystore";
|
import { PgSessionStore } from "./sessionStore.js";
|
||||||
import passport from "passport";
|
import passport from "passport";
|
||||||
import { refresh } from "./refresh.js";
|
import { refresh } from "./refresh.js";
|
||||||
import { logout } from "./logout.js";
|
import { logout } from "./logout.js";
|
||||||
import { logRequest } from "./logging.js";
|
import { logRequest } from "./logging.js";
|
||||||
import { routeAgreements } from "./agreements.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 { getUsage } from "../modules/core/usage.js";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
async function render(view, res, config) {
|
async function render(view, res, config) {
|
||||||
try {
|
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) {
|
export async function initHTTP(app) {
|
||||||
const config = getConfig();
|
const config = getConfig();
|
||||||
|
const archiveServers = await getArchiveServers(config);
|
||||||
|
|
||||||
passport.serializeUser(function (user, done) {
|
passport.serializeUser(function (user, done) {
|
||||||
done(null, user);
|
done(null, user);
|
||||||
@@ -58,19 +76,16 @@ export async function initHTTP(app) {
|
|||||||
done(null, user);
|
done(null, user);
|
||||||
});
|
});
|
||||||
|
|
||||||
const memoryStore = MemoryStore(session);
|
|
||||||
const sess = {
|
const sess = {
|
||||||
secret: config.secureSecret,
|
secret: config.secureSecret,
|
||||||
resave: false,
|
resave: false,
|
||||||
saveUninitialized: false,
|
saveUninitialized: false,
|
||||||
store: new memoryStore({
|
store: new PgSessionStore(),
|
||||||
checkPeriod: 86400000 // prune expired entries every 24h
|
cookie: { httpOnly: true, sameSite: 'lax' }, // sameSite=lax: CSRF defense
|
||||||
}),
|
|
||||||
cookie: {},
|
|
||||||
}
|
}
|
||||||
if (app.get('env') === 'production') {
|
if (app.get('env') === 'production') {
|
||||||
app.set('trust proxy', 1) // trust first proxy
|
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(session(sess));
|
||||||
app.use(passport.initialize());
|
app.use(passport.initialize());
|
||||||
@@ -91,7 +106,27 @@ export async function initHTTP(app) {
|
|||||||
app.get("/refresh", refresh);
|
app.get("/refresh", refresh);
|
||||||
app.post('/logout', logout);
|
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.post(/^\/api\/v1\/.*$/, bodyParser.json(), apiMiddleware, core.post);
|
||||||
|
|
||||||
// app.use("/api/v1", swaggerUi.serve, swaggerUi.setup(swaggerDocument));
|
// app.use("/api/v1", swaggerUi.serve, swaggerUi.setup(swaggerDocument));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.7 KiB |
115
backend/http/sessionStore.js
Normal file
115
backend/http/sessionStore.js
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
160
backend/http/sessionStore.test.js
Normal file
160
backend/http/sessionStore.test.js
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
<%- include('./include/header.ejs', { title: 'JLINC - Dashboard' }) %>
|
<%- include('./include/header.ejs', { title: 'JLINC - Dashboard' }) %>
|
||||||
|
|
||||||
<% for (const type in config.appModules) { %>
|
<% for (const type in config.appModules) { %>
|
||||||
|
<% if (config.appModules[type].internal) { continue; } %>
|
||||||
<%-
|
<%-
|
||||||
include('./include/app.ejs', {
|
include('./include/app.ejs', {
|
||||||
app: config.appModules[type],
|
app: config.appModules[type],
|
||||||
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 2.0 KiB |
@@ -3,6 +3,7 @@ import { init, close, migrate, populateAgreements } from "./db/index.js";
|
|||||||
import { loadConfig } from "./common/config.js";
|
import { loadConfig } from "./common/config.js";
|
||||||
import { loadApps } from "./apps.js";
|
import { loadApps } from "./apps.js";
|
||||||
import { initHTTP } from "./http/index.js";
|
import { initHTTP } from "./http/index.js";
|
||||||
|
import { migrateLegacyApiKeys } from "./modules/core/apiKey.js";
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
app.set("env", "development");
|
app.set("env", "development");
|
||||||
@@ -15,6 +16,8 @@ async function main() {
|
|||||||
await populateAgreements();
|
await populateAgreements();
|
||||||
await loadApps();
|
await loadApps();
|
||||||
await initHTTP(app);
|
await initHTTP(app);
|
||||||
|
// After initHTTP so the auth rows (incl. the single/Docker user's) exist.
|
||||||
|
await migrateLegacyApiKeys();
|
||||||
const server = await app.listen(9090, () => {
|
const server = await app.listen(9090, () => {
|
||||||
console.log(`Listening on 0.0.0.0:9090`);
|
console.log(`Listening on 0.0.0.0:9090`);
|
||||||
});
|
});
|
||||||
8
backend/jest.config.js
Normal file
8
backend/jest.config.js
Normal 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,
|
||||||
|
};
|
||||||
10
backend/modules/app/dashboard.js
Normal file
10
backend/modules/app/dashboard.js
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
// internal: no `app` row, not API-key mintable, excluded from the endpoint count.
|
||||||
|
const key = 'dashboard';
|
||||||
|
|
||||||
|
export function getModuleConfig() {
|
||||||
|
return {
|
||||||
|
title: 'JLINC Dashboard',
|
||||||
|
type: key,
|
||||||
|
internal: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -40,7 +40,7 @@ export async function initModule(app, passport) {
|
|||||||
failureMessage: true
|
failureMessage: true
|
||||||
}),
|
}),
|
||||||
function (req, res) {
|
function (req, res) {
|
||||||
res.redirect('/dashboard');
|
res.redirect('/home');
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -40,7 +40,7 @@ export async function initModule(app, passport) {
|
|||||||
failureMessage: true
|
failureMessage: true
|
||||||
}),
|
}),
|
||||||
function (req, res) {
|
function (req, res) {
|
||||||
res.redirect('/dashboard');
|
res.redirect('/home');
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -7,7 +7,7 @@ const key = 'oidc';
|
|||||||
export function getModuleConfig() {
|
export function getModuleConfig() {
|
||||||
const config = getConfig();
|
const config = getConfig();
|
||||||
return {
|
return {
|
||||||
title: 'FedID',
|
title: process.env.OIDC_BUTTON_LABEL || 'FedID',
|
||||||
icon: 'login',
|
icon: 'login',
|
||||||
issuer: process.env.OIDC_ISSUER,
|
issuer: process.env.OIDC_ISSUER,
|
||||||
authorizationURL: process.env.OIDC_AUTHORIZATION_URL,
|
authorizationURL: process.env.OIDC_AUTHORIZATION_URL,
|
||||||
@@ -46,7 +46,7 @@ export async function initModule(app, passport) {
|
|||||||
}),
|
}),
|
||||||
function (req, res) {
|
function (req, res) {
|
||||||
req.session.authStrategy = 'oidc';
|
req.session.authStrategy = 'oidc';
|
||||||
res.redirect('/dashboard');
|
res.redirect('/home');
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
96
backend/modules/core/apiKey.cache.test.js
Normal file
96
backend/modules/core/apiKey.cache.test.js
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import { jest } from "@jest/globals";
|
||||||
|
|
||||||
|
// The bound is read per call from config, so set it before the first verify
|
||||||
|
// rather than before the import. Own file so the tiny bound cannot leak into
|
||||||
|
// apiKey.test.js.
|
||||||
|
process.env.DASHBOARD_KEY_CACHE_MAX = "2";
|
||||||
|
|
||||||
|
const query = jest.fn();
|
||||||
|
const release = jest.fn(async () => {});
|
||||||
|
jest.unstable_mockModule("../../db/index.js", () => ({
|
||||||
|
getPool: async () => ({ query, release }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { loadConfig } = await import("../../common/config.js");
|
||||||
|
await loadConfig();
|
||||||
|
|
||||||
|
const { verifyKey, generateKey, _resetKeyCache } = await import("./apiKey.js");
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
query.mockReset();
|
||||||
|
release.mockClear();
|
||||||
|
_resetKeyCache();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function mintKey(id) {
|
||||||
|
query.mockReset();
|
||||||
|
query
|
||||||
|
.mockResolvedValueOnce({ rows: [{ id: 2 }] }) // SELECT app
|
||||||
|
.mockResolvedValueOnce({ rows: [{ id, created_ts: "2026-01-01" }] }); // INSERT
|
||||||
|
const out = await generateKey(1, "core", { label: `k${id}` });
|
||||||
|
return { raw: out.key, keyHash: query.mock.calls[1][1][3], id };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verifyWithDb({ raw, keyHash, id }) {
|
||||||
|
query.mockReset();
|
||||||
|
query
|
||||||
|
.mockResolvedValueOnce({ rows: [{ id, user_id: 1, app_id: 2, key_hash: keyHash }] })
|
||||||
|
.mockResolvedValue({ rows: [] });
|
||||||
|
const result = await verifyKey(raw);
|
||||||
|
return { result, selects: query.mock.calls.filter((c) => /SELECT/.test(c[0])) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function servedFromCache({ raw }) {
|
||||||
|
query.mockReset();
|
||||||
|
query.mockResolvedValue({ rows: [] }); // if it does hit the DB, it finds nothing
|
||||||
|
const result = await verifyKey(raw);
|
||||||
|
return result !== null && query.mock.calls.length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("validated-key cache bound", () => {
|
||||||
|
it("evicts the least recently used key once the cache is full", async () => {
|
||||||
|
const a = await mintKey(11);
|
||||||
|
const b = await mintKey(12);
|
||||||
|
const c = await mintKey(13);
|
||||||
|
|
||||||
|
// Fill the cache to its bound of 2.
|
||||||
|
await verifyWithDb(a);
|
||||||
|
await verifyWithDb(b);
|
||||||
|
|
||||||
|
// A hit counts as a use, so probes below check the EVICTED key first.
|
||||||
|
expect(await servedFromCache(a)).toBe(true);
|
||||||
|
|
||||||
|
await verifyWithDb(c);
|
||||||
|
expect(await servedFromCache(b)).toBe(false);
|
||||||
|
expect(await servedFromCache(a)).toBe(true);
|
||||||
|
expect(await servedFromCache(c)).toBe(true);
|
||||||
|
|
||||||
|
const { result, selects } = await verifyWithDb(b);
|
||||||
|
expect(result).toEqual({ user_id: 1, app_id: 2 });
|
||||||
|
expect(selects).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never grows past the bound, however many distinct keys verify", async () => {
|
||||||
|
const keys = [];
|
||||||
|
for (let i = 0; i < 6; i++) keys.push(await mintKey(20 + i));
|
||||||
|
for (const k of keys) await verifyWithDb(k);
|
||||||
|
|
||||||
|
const cached = [];
|
||||||
|
for (const k of keys) if (await servedFromCache(k)) cached.push(k.id);
|
||||||
|
expect(cached).toEqual([keys[4].id, keys[5].id]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a cache hit refreshes recency without extending the entry's TTL", async () => {
|
||||||
|
const a = await mintKey(31);
|
||||||
|
await verifyWithDb(a);
|
||||||
|
|
||||||
|
const realNow = Date.now;
|
||||||
|
try {
|
||||||
|
// Must expire on its original deadline despite being touched.
|
||||||
|
Date.now = () => realNow() + 31 * 60 * 1000;
|
||||||
|
expect(await servedFromCache(a)).toBe(false);
|
||||||
|
} finally {
|
||||||
|
Date.now = realNow;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
193
backend/modules/core/apiKey.js
Normal file
193
backend/modules/core/apiKey.js
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
import crypto from "crypto";
|
||||||
|
import { promisify } from "util";
|
||||||
|
import { getPool } from "../../db/index.js";
|
||||||
|
import { getConfig } from "../../common/config.js";
|
||||||
|
|
||||||
|
const scrypt = promisify(crypto.scrypt);
|
||||||
|
const KEYLEN = 64;
|
||||||
|
|
||||||
|
// Read per call rather than captured at import, so loadConfig() governs these
|
||||||
|
// the same way it governs the rest of config.dashboard.
|
||||||
|
//
|
||||||
|
// Per-process, so a revoked key can linger in another worker for one TTL.
|
||||||
|
const cacheTtlMs = () => getConfig().dashboard.keyCache.ttlMs;
|
||||||
|
|
||||||
|
// The TTL alone does not bound the cache: an expired entry is only dropped when
|
||||||
|
// that same key is looked up again, so idle keys would accumulate forever.
|
||||||
|
const cacheMaxEntries = () => getConfig().dashboard.keyCache.max;
|
||||||
|
|
||||||
|
// Keyed by a digest of the raw key, never the raw key. Insertion order is
|
||||||
|
// recency order, so the front of the Map is the least-recently-used entry.
|
||||||
|
const cache = new Map();
|
||||||
|
const fastDigest = (raw) => crypto.createHash("sha256").update(raw).digest("hex");
|
||||||
|
|
||||||
|
// `expiresAt` is deliberately untouched, so a hammered key still expires on
|
||||||
|
// time and revocation staleness stays bounded at one TTL.
|
||||||
|
function touch(dg, entry) {
|
||||||
|
cache.delete(dg);
|
||||||
|
cache.set(dg, entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sweeps only when full, so the common insert stays O(1).
|
||||||
|
function remember(dg, entry) {
|
||||||
|
const max = cacheMaxEntries();
|
||||||
|
if (cacheTtlMs() <= 0) return;
|
||||||
|
if (cache.size >= max) {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [k, v] of cache) if (v.expiresAt <= now) cache.delete(k);
|
||||||
|
}
|
||||||
|
cache.set(dg, entry);
|
||||||
|
while (cache.size > max) {
|
||||||
|
const lru = cache.keys().next().value;
|
||||||
|
if (lru === undefined) break;
|
||||||
|
cache.delete(lru);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function _resetKeyCache() {
|
||||||
|
cache.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
// "<saltHex>:<derivedHex>". Guards a DB dump, not a weak-password guess.
|
||||||
|
async function scryptHash(raw) {
|
||||||
|
const salt = crypto.randomBytes(16);
|
||||||
|
const derived = await scrypt(raw, salt, KEYLEN);
|
||||||
|
return `${salt.toString("hex")}:${derived.toString("hex")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function scryptVerify(raw, stored) {
|
||||||
|
const [saltHex, derivedHex] = String(stored).split(":");
|
||||||
|
if (!saltHex || !derivedHex) return false;
|
||||||
|
const expected = Buffer.from(derivedHex, "hex");
|
||||||
|
const derived = await scrypt(raw, Buffer.from(saltHex, "hex"), KEYLEN);
|
||||||
|
return derived.length === expected.length && crypto.timingSafeEqual(derived, expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
const newRawKey = () => crypto.randomBytes(32).toString("hex"); // 64 hex chars
|
||||||
|
const prefixOf = (raw) => raw.slice(0, 8);
|
||||||
|
|
||||||
|
export async function verifyKey(rawKey) {
|
||||||
|
if (!rawKey) return null;
|
||||||
|
const dg = fastDigest(rawKey);
|
||||||
|
|
||||||
|
const cached = cache.get(dg);
|
||||||
|
if (cached && cached.expiresAt > Date.now()) {
|
||||||
|
touch(dg, cached);
|
||||||
|
return { user_id: cached.user_id, app_id: cached.app_id };
|
||||||
|
}
|
||||||
|
if (cached) cache.delete(dg);
|
||||||
|
|
||||||
|
const client = await getPool();
|
||||||
|
try {
|
||||||
|
const rows = (
|
||||||
|
await client.query(
|
||||||
|
`SELECT id, user_id, app_id, key_hash FROM api_key
|
||||||
|
WHERE key_prefix = $1 AND (expires_ts IS NULL OR expires_ts > NOW())`,
|
||||||
|
[prefixOf(rawKey)],
|
||||||
|
)
|
||||||
|
).rows;
|
||||||
|
for (const row of rows) {
|
||||||
|
if (await scryptVerify(rawKey, row.key_hash)) {
|
||||||
|
client.query(`UPDATE api_key SET last_used_ts = NOW() WHERE id = $1`, [row.id]).catch(() => {});
|
||||||
|
remember(dg, { user_id: row.user_id, app_id: row.app_id, id: row.id, expiresAt: Date.now() + cacheTtlMs() });
|
||||||
|
return { user_id: row.user_id, app_id: row.app_id };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
await client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns the RAW key ONCE; only the salted hash + prefix are persisted.
|
||||||
|
export async function generateKey(userId, appType, { label = null, expiresTs = null } = {}) {
|
||||||
|
const client = await getPool();
|
||||||
|
try {
|
||||||
|
const app = (await client.query(`SELECT id FROM app WHERE type = $1`, [appType])).rows[0];
|
||||||
|
if (!app) throw new Error(`unknown app type: ${appType}`);
|
||||||
|
const raw = newRawKey();
|
||||||
|
const keyHash = await scryptHash(raw);
|
||||||
|
const res = await client.query(
|
||||||
|
`INSERT INTO api_key (user_id, app_id, label, key_hash, key_prefix, expires_ts)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6) RETURNING id, created_ts`,
|
||||||
|
[userId, app.id, label, keyHash, prefixOf(raw), expiresTs],
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
id: res.rows[0].id,
|
||||||
|
key: raw,
|
||||||
|
prefix: prefixOf(raw),
|
||||||
|
appType,
|
||||||
|
label,
|
||||||
|
expiresTs,
|
||||||
|
createdTs: res.rows[0].created_ts,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
await client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Idempotent import of the legacy plaintext keys in the `auth` table into the
|
||||||
|
// hashed api_key store, so the same keys keep authenticating via verifyKey and
|
||||||
|
// now appear in the /home API-key screen.
|
||||||
|
export async function migrateLegacyApiKeys() {
|
||||||
|
const client = await getPool();
|
||||||
|
try {
|
||||||
|
const { rows } = await client.query(
|
||||||
|
`SELECT user_id, app_id, api_key FROM public.auth WHERE api_key IS NOT NULL`,
|
||||||
|
);
|
||||||
|
let imported = 0;
|
||||||
|
for (const { user_id, app_id, api_key } of rows) {
|
||||||
|
const prefix = prefixOf(api_key);
|
||||||
|
const exists = await client.query(
|
||||||
|
`SELECT 1 FROM api_key WHERE user_id = $1 AND app_id = $2 AND key_prefix = $3 LIMIT 1`,
|
||||||
|
[user_id, app_id, prefix],
|
||||||
|
);
|
||||||
|
if (exists.rowCount > 0) continue;
|
||||||
|
const keyHash = await scryptHash(api_key);
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO api_key (user_id, app_id, label, key_hash, key_prefix)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)`,
|
||||||
|
[user_id, app_id, "Imported from /dashboard", keyHash, prefix],
|
||||||
|
);
|
||||||
|
imported++;
|
||||||
|
}
|
||||||
|
if (imported > 0) console.log(`Imported ${imported} legacy API key(s) into the hashed key store`);
|
||||||
|
return imported;
|
||||||
|
} finally {
|
||||||
|
await client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listKeys(userId) {
|
||||||
|
const client = await getPool();
|
||||||
|
try {
|
||||||
|
return (
|
||||||
|
await client.query(
|
||||||
|
`SELECT k.id, ap.type AS "appType", k.label, k.key_prefix AS "prefix",
|
||||||
|
k.expires_ts AS "expiresTs", k.last_used_ts AS "lastUsedTs", k.created_ts AS "createdTs"
|
||||||
|
FROM api_key k JOIN app ap ON ap.id = k.app_id
|
||||||
|
WHERE k.user_id = $1
|
||||||
|
ORDER BY k.created_ts DESC`,
|
||||||
|
[userId],
|
||||||
|
)
|
||||||
|
).rows;
|
||||||
|
} finally {
|
||||||
|
await client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function revokeKey(userId, id) {
|
||||||
|
const client = await getPool();
|
||||||
|
try {
|
||||||
|
const res = await client.query(
|
||||||
|
`DELETE FROM api_key WHERE id = $1 AND user_id = $2 RETURNING id`,
|
||||||
|
[id, userId],
|
||||||
|
);
|
||||||
|
if (res.rowCount > 0) {
|
||||||
|
for (const [k, v] of cache) if (v.id === id) cache.delete(k);
|
||||||
|
}
|
||||||
|
return res.rowCount > 0;
|
||||||
|
} finally {
|
||||||
|
await client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
144
backend/modules/core/apiKey.test.js
Normal file
144
backend/modules/core/apiKey.test.js
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
import { jest } from "@jest/globals";
|
||||||
|
|
||||||
|
// Manual DB mock: getPool() returns a fake pooled client whose query() the test
|
||||||
|
// drives. No real database, no injection seams — the module under test calls the
|
||||||
|
// real getPool(), which Jest replaces here.
|
||||||
|
const query = jest.fn();
|
||||||
|
const release = jest.fn(async () => {});
|
||||||
|
jest.unstable_mockModule("../../db/index.js", () => ({
|
||||||
|
getPool: async () => ({ query, release }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { verifyKey, generateKey, listKeys, revokeKey, migrateLegacyApiKeys, _resetKeyCache } = await import("./apiKey.js");
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
query.mockReset();
|
||||||
|
release.mockClear();
|
||||||
|
_resetKeyCache();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mint a key through generateKey and capture the stored (salted) hash, so the
|
||||||
|
// verifyKey tests can round-trip a real scrypt hash without a DB.
|
||||||
|
async function mintKey({ userId = 1, appId = 2 } = {}) {
|
||||||
|
query
|
||||||
|
.mockResolvedValueOnce({ rows: [{ id: appId }] }) // SELECT app
|
||||||
|
.mockResolvedValueOnce({ rows: [{ id: 9, created_ts: "2026-01-01" }] }); // INSERT
|
||||||
|
const out = await generateKey(userId, "core", { label: "ci" });
|
||||||
|
const insertParams = query.mock.calls[1][1];
|
||||||
|
return { raw: out.key, keyHash: insertParams[3], out };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("generateKey", () => {
|
||||||
|
it("stores a salted scrypt hash (not the raw key) and returns the raw key once", async () => {
|
||||||
|
const { raw, keyHash, out } = await mintKey();
|
||||||
|
expect(raw).toMatch(/^[0-9a-f]{64}$/);
|
||||||
|
expect(out).toMatchObject({ id: 9, prefix: raw.slice(0, 8), appType: "core", label: "ci" });
|
||||||
|
// Stored hash is "<saltHex>:<derivedHex>" and never contains the raw key.
|
||||||
|
expect(keyHash).toMatch(/^[0-9a-f]{32}:[0-9a-f]{128}$/);
|
||||||
|
expect(keyHash).not.toContain(raw);
|
||||||
|
expect(query.mock.calls[1][1]).not.toContain(raw);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws for an unknown app type", async () => {
|
||||||
|
query.mockResolvedValueOnce({ rows: [] }); // app lookup empty
|
||||||
|
await expect(generateKey(1, "nope", {})).rejects.toThrow("unknown app type");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("verifyKey", () => {
|
||||||
|
it("returns null for an empty key without touching the DB", async () => {
|
||||||
|
expect(await verifyKey("")).toBeNull();
|
||||||
|
expect(query).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("verifies a real key by prefix + scrypt, then serves the cache without a DB query", async () => {
|
||||||
|
const { raw, keyHash } = await mintKey();
|
||||||
|
query.mockReset();
|
||||||
|
// First verify: prefix lookup returns the candidate, UPDATE last_used is best-effort.
|
||||||
|
query
|
||||||
|
.mockResolvedValueOnce({ rows: [{ id: 9, user_id: 1, app_id: 2, key_hash: keyHash }] })
|
||||||
|
.mockResolvedValue({ rows: [] });
|
||||||
|
|
||||||
|
const first = await verifyKey(raw);
|
||||||
|
expect(first).toEqual({ user_id: 1, app_id: 2 });
|
||||||
|
expect(query.mock.calls[0][1]).toEqual([raw.slice(0, 8)]); // looked up by prefix
|
||||||
|
|
||||||
|
// Second verify: served from the 30-min cache — no new SELECT.
|
||||||
|
query.mockClear();
|
||||||
|
const second = await verifyKey(raw);
|
||||||
|
expect(second).toEqual({ user_id: 1, app_id: 2 });
|
||||||
|
const selects = query.mock.calls.filter((c) => /SELECT/.test(c[0]));
|
||||||
|
expect(selects).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when a candidate's hash does not match the raw key", async () => {
|
||||||
|
const { keyHash } = await mintKey();
|
||||||
|
query.mockReset();
|
||||||
|
query.mockResolvedValueOnce({ rows: [{ id: 9, user_id: 1, app_id: 2, key_hash: keyHash }] });
|
||||||
|
expect(await verifyKey("some-other-raw-key-that-does-not-match")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when no candidate row matches the prefix", async () => {
|
||||||
|
query.mockResolvedValueOnce({ rows: [] });
|
||||||
|
expect(await verifyKey("deadbeefcafefeed")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("listKeys", () => {
|
||||||
|
it("returns metadata rows for the user", async () => {
|
||||||
|
const rows = [{ id: 1, appType: "core", prefix: "ab12cd34" }];
|
||||||
|
query.mockResolvedValueOnce({ rows });
|
||||||
|
expect(await listKeys(7)).toEqual(rows);
|
||||||
|
expect(query.mock.calls[0][1]).toEqual([7]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("revokeKey", () => {
|
||||||
|
it("deletes the user's key and evicts its cached validation", async () => {
|
||||||
|
// Prime the cache by verifying a real key (id 9).
|
||||||
|
const { raw, keyHash } = await mintKey();
|
||||||
|
query.mockReset();
|
||||||
|
query
|
||||||
|
.mockResolvedValueOnce({ rows: [{ id: 9, user_id: 1, app_id: 2, key_hash: keyHash }] })
|
||||||
|
.mockResolvedValue({ rows: [] });
|
||||||
|
await verifyKey(raw);
|
||||||
|
|
||||||
|
// Revoke id 9 -> deletes, and the cached entry is evicted so the next verify re-hits the DB.
|
||||||
|
query.mockReset();
|
||||||
|
query.mockResolvedValueOnce({ rowCount: 1, rows: [{ id: 9 }] });
|
||||||
|
expect(await revokeKey(7, 9)).toBe(true);
|
||||||
|
expect(query.mock.calls[0][1]).toEqual([9, 7]); // scoped to (id, user_id)
|
||||||
|
|
||||||
|
query.mockReset();
|
||||||
|
query.mockResolvedValueOnce({ rows: [] }); // no candidate now
|
||||||
|
expect(await verifyKey(raw)).toBeNull(); // cache was evicted -> DB consulted again
|
||||||
|
expect(query.mock.calls[0][0]).toMatch(/SELECT/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when nothing was deleted", async () => {
|
||||||
|
query.mockResolvedValueOnce({ rowCount: 0, rows: [] });
|
||||||
|
expect(await revokeKey(7, 3)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("migrateLegacyApiKeys", () => {
|
||||||
|
it("imports each legacy key once, hashed and with its prefix", async () => {
|
||||||
|
query
|
||||||
|
.mockResolvedValueOnce({ rows: [{ user_id: 1, app_id: 2, api_key: "abcdef1234567890" }] }) // SELECT auth
|
||||||
|
.mockResolvedValueOnce({ rowCount: 0, rows: [] }) // existence check -> not present
|
||||||
|
.mockResolvedValueOnce({ rows: [] }); // INSERT
|
||||||
|
expect(await migrateLegacyApiKeys()).toBe(1);
|
||||||
|
const [sql, params] = query.mock.calls[2];
|
||||||
|
expect(sql).toMatch(/INSERT INTO api_key/);
|
||||||
|
expect(params[4]).toBe("abcdef12"); // key_prefix = first 8 chars of the raw key
|
||||||
|
expect(params[3]).toContain(":"); // salted scrypt hash "<saltHex>:<derivedHex>"
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips a key that was already imported (idempotent)", async () => {
|
||||||
|
query
|
||||||
|
.mockResolvedValueOnce({ rows: [{ user_id: 1, app_id: 2, api_key: "abcdef1234567890" }] }) // SELECT auth
|
||||||
|
.mockResolvedValueOnce({ rowCount: 1, rows: [{ ok: 1 }] }); // existence check -> present
|
||||||
|
expect(await migrateLegacyApiKeys()).toBe(0);
|
||||||
|
expect(query).toHaveBeenCalledTimes(2); // SELECT + existence check, no INSERT
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,6 +3,7 @@ const { JlincAgreement, JlincAudit } = pkg;
|
|||||||
import { getPool } from "../../../db/index.js";
|
import { getPool } from "../../../db/index.js";
|
||||||
import { entity } from "./entity.js";
|
import { entity } from "./entity.js";
|
||||||
import { putQueue } from "../../../common/queue.js";
|
import { putQueue } from "../../../common/queue.js";
|
||||||
|
import { cacheAgreementMarkdown } from "../../../util/cacheAgreement.js";
|
||||||
|
|
||||||
async function getAgreement(client, userId, id, key) {
|
async function getAgreement(client, userId, id, key) {
|
||||||
const whereClause = id ? `a.agreement_id_uuid = $1` : `a.agreement_id_uuid = (SELECT agreement_uuid FROM agreement_content WHERE lower(key) = lower($1))`;
|
const 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;
|
agreement.agreementId = uuid;
|
||||||
}
|
}
|
||||||
await save(client, userId, agreement, input.public);
|
await save(client, userId, agreement, input.public);
|
||||||
|
for (const uri of (agreement.references || [])) {
|
||||||
|
await cacheAgreementMarkdown({ userId, agreementUuid: agreement.agreementId, uri }, client);
|
||||||
|
}
|
||||||
response = {
|
response = {
|
||||||
message: `created and saved: ${agreement.agreementId}`,
|
message: `created and saved: ${agreement.agreementId}`,
|
||||||
data: agreement,
|
data: agreement,
|
||||||
@@ -134,10 +134,12 @@ async function verify(input, userId) {
|
|||||||
valid: [],
|
valid: [],
|
||||||
invalid: [],
|
invalid: [],
|
||||||
};
|
};
|
||||||
|
if (!input.didDocs) {
|
||||||
input.didDocs = [];
|
input.didDocs = [];
|
||||||
for (const shortName of input.shortNames) {
|
for (const shortName of input.shortNames || []) {
|
||||||
input.didDocs.push((await entity.getEntity(client, userId, shortName)).didDoc)
|
input.didDocs.push((await entity.getEntity(client, userId, shortName)).didDoc)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
delete input.shortNames;
|
delete input.shortNames;
|
||||||
const organizedById = [];
|
const organizedById = [];
|
||||||
for (const item of input.audits) {
|
for (const item of input.audits) {
|
||||||
@@ -3,6 +3,7 @@ const { JlincEvent, JlincAudit } = pkg;
|
|||||||
import { getPool } from "../../../db/index.js";
|
import { getPool } from "../../../db/index.js";
|
||||||
import { entity } from "./entity.js";
|
import { entity } from "./entity.js";
|
||||||
import { putQueue } from "../../../common/queue.js";
|
import { putQueue } from "../../../common/queue.js";
|
||||||
|
import { cacheAgreementMarkdown } from "../../../util/cacheAgreement.js";
|
||||||
|
|
||||||
async function getEvent(client, userId, id, includeData, meta) {
|
async function getEvent(client, userId, id, includeData, meta) {
|
||||||
const dataSql = includeData
|
const dataSql = includeData
|
||||||
@@ -52,12 +53,12 @@ async function getEvent(client, userId, id, includeData, meta) {
|
|||||||
'senderId', e.sender_id,
|
'senderId', e.sender_id,
|
||||||
'recipientId', e.recipient_id,
|
'recipientId', e.recipient_id,
|
||||||
'created', e.created,
|
'created', e.created,
|
||||||
'agreementId', (
|
'agreementId', COALESCE((
|
||||||
SELECT a.agreement_id_uuid
|
SELECT a.agreement_id_uuid
|
||||||
FROM agreement a
|
FROM agreement a
|
||||||
WHERE a.id = e.agreement_id
|
WHERE a.id = e.agreement_id
|
||||||
AND a.user_id = $1
|
AND a.user_id = $1
|
||||||
),
|
), '00000000-0000-0000-0000-000000000000'),
|
||||||
${dataSql}
|
${dataSql}
|
||||||
'created', e.created
|
'created', e.created
|
||||||
) AS record
|
) AS record
|
||||||
@@ -286,6 +287,12 @@ async function create(input, userId, _client, _sender) {
|
|||||||
delete input.recipientShortName
|
delete input.recipientShortName
|
||||||
const event = await JlincEvent.create(input);
|
const event = await JlincEvent.create(input);
|
||||||
await save(client, userId, event, input.meta);
|
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 = {
|
response = {
|
||||||
message: `created and saved: ${event.eventId}`,
|
message: `created and saved: ${event.eventId}`,
|
||||||
data: event,
|
data: event,
|
||||||
@@ -6,8 +6,29 @@ import { data } from "./data/index.js";
|
|||||||
import { archive } from "./archive.js";
|
import { archive } from "./archive.js";
|
||||||
import { trackUsage } from "./usage.js";
|
import { trackUsage } from "./usage.js";
|
||||||
import { getConfig } from "../../common/config.js";
|
import { getConfig } from "../../common/config.js";
|
||||||
|
import { dashboard } from "../dashboard/index.js";
|
||||||
import { evaluate } from "../pep/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) {
|
async function post(req, res) {
|
||||||
let response = {
|
let response = {
|
||||||
success: false,
|
success: false,
|
||||||
@@ -155,11 +176,20 @@ async function post(req, res) {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!type) {
|
}
|
||||||
|
// 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';
|
response.error = 'Page not found';
|
||||||
errorCode = 404;
|
errorCode = 404;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
response.error = e.message;
|
response.error = e.message;
|
||||||
@@ -185,7 +215,11 @@ async function post(req, res) {
|
|||||||
req.apiMessage = response.message;
|
req.apiMessage = response.message;
|
||||||
res.status(200).json(response.data);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { getPool } from "../../db/index.js";
|
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();
|
const client = await getPool();
|
||||||
try {
|
try {
|
||||||
await client.query(`
|
await client.query(`
|
||||||
@@ -19,7 +19,9 @@ export async function trackUsage(user_id, url, type, success) {
|
|||||||
INSERT INTO usage (
|
INSERT INTO usage (
|
||||||
user_id,
|
user_id,
|
||||||
usage_url_id,
|
usage_url_id,
|
||||||
success
|
success,
|
||||||
|
event_id_uuid,
|
||||||
|
agreement_id_uuid
|
||||||
) VALUES (
|
) VALUES (
|
||||||
$1,
|
$1,
|
||||||
(
|
(
|
||||||
@@ -27,12 +29,16 @@ export async function trackUsage(user_id, url, type, success) {
|
|||||||
FROM usage_url
|
FROM usage_url
|
||||||
WHERE url = $2
|
WHERE url = $2
|
||||||
),
|
),
|
||||||
$3
|
$3,
|
||||||
|
$4,
|
||||||
|
$5
|
||||||
);
|
);
|
||||||
`, [
|
`, [
|
||||||
user_id,
|
user_id,
|
||||||
url,
|
url,
|
||||||
success,
|
success,
|
||||||
|
eventId,
|
||||||
|
agreementId,
|
||||||
]);
|
]);
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
console.error(e)
|
console.error(e)
|
||||||
12
backend/modules/dashboard/account.js
Normal file
12
backend/modules/dashboard/account.js
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { getPool } from "../../db/index.js";
|
||||||
|
|
||||||
|
// Legacy `auth` table only; the user-managed api_key store is counted separately.
|
||||||
|
export async function countApiKeys(userId) {
|
||||||
|
const client = await getPool();
|
||||||
|
try {
|
||||||
|
const r = await client.query(`SELECT COUNT(*)::int AS n FROM auth WHERE user_id = $1`, [userId]);
|
||||||
|
return r.rows[0].n;
|
||||||
|
} finally {
|
||||||
|
await client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
50
backend/modules/dashboard/api.js
Normal file
50
backend/modules/dashboard/api.js
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { service as defaultService } from "./service.js";
|
||||||
|
import { parseWindow, parseTransactionsQuery, parseVerifyTargets, isUuid } from "./validate.js";
|
||||||
|
|
||||||
|
// `input` is the request body on the API-key path and the query string on the
|
||||||
|
// browser path; both are plain string maps, so one parser serves both.
|
||||||
|
// `status` is for the browser routes — the API-key router ignores it.
|
||||||
|
export function makeDashboardApi(service = defaultService) {
|
||||||
|
return {
|
||||||
|
async summary(input, userId) {
|
||||||
|
return { message: "dashboard summary", status: 200, data: await service.summary(userId, parseWindow(input)) };
|
||||||
|
},
|
||||||
|
|
||||||
|
async transactions(input, userId) {
|
||||||
|
const parsed = parseTransactionsQuery(input);
|
||||||
|
if (!parsed.ok) return { message: "invalid request", status: 400, data: { error: parsed.error } };
|
||||||
|
return { message: "dashboard transactions", status: 200, data: await service.transactions(userId, parsed.value) };
|
||||||
|
},
|
||||||
|
|
||||||
|
async series(input, userId) {
|
||||||
|
return { message: "dashboard series", status: 200, data: await service.series(userId, parseWindow(input)) };
|
||||||
|
},
|
||||||
|
|
||||||
|
async verify(input, userId) {
|
||||||
|
return { message: "dashboard verify", status: 200, data: await service.verify(userId, parseVerifyTargets(input)) };
|
||||||
|
},
|
||||||
|
|
||||||
|
async event(input, userId) {
|
||||||
|
if (!isUuid(input?.eventUuid)) return { message: "invalid request", status: 400, data: { error: "invalid event id" } };
|
||||||
|
const record = await service.event(userId, input.eventUuid);
|
||||||
|
if (!record) return { message: "dashboard event", status: 404, data: { error: "event not found" } };
|
||||||
|
return { message: "dashboard event", status: 200, data: record };
|
||||||
|
},
|
||||||
|
|
||||||
|
async agreement(input, userId) {
|
||||||
|
if (!isUuid(input?.agreementUuid)) return { message: "invalid request", status: 400, data: { error: "invalid agreement id" } };
|
||||||
|
const record = await service.agreement(userId, input.agreementUuid);
|
||||||
|
if (!record) return { message: "dashboard agreement", status: 404, data: { error: "agreement not found" } };
|
||||||
|
return { message: "dashboard agreement", status: 200, data: record };
|
||||||
|
},
|
||||||
|
|
||||||
|
async details(input, userId) {
|
||||||
|
if (!isUuid(input?.eventUuid)) return { message: "invalid request", status: 400, data: { error: "invalid event id" } };
|
||||||
|
const record = await service.details(userId, input.eventUuid);
|
||||||
|
if(!record) return { message: "dashboard event details", status: 404, data: { error: "event details not found" } };
|
||||||
|
return { message: "dashboard event details", status: 200, data: record };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const dashboardApi = makeDashboardApi();
|
||||||
60
backend/modules/dashboard/api.test.js
Normal file
60
backend/modules/dashboard/api.test.js
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { jest } from "@jest/globals";
|
||||||
|
import { makeDashboardApi } from "./api.js";
|
||||||
|
|
||||||
|
const fakeService = () => ({
|
||||||
|
summary: jest.fn(async () => ({ apiUsage: { total: 3 } })),
|
||||||
|
transactions: jest.fn(async (_u, opts) => ({ rows: [], total: 0, limit: opts.limit, offset: opts.offset })),
|
||||||
|
series: jest.fn(async () => ({ usage: [], storage: [] })),
|
||||||
|
verify: jest.fn(async (_u, targets) => ({ results: targets })),
|
||||||
|
event: jest.fn(async () => ({ eventId: "x", data: { a: 1 } })),
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("dashboard API adapter", () => {
|
||||||
|
it("summary wraps the service result with a message + window", async () => {
|
||||||
|
const service = fakeService();
|
||||||
|
const out = await makeDashboardApi(service).summary({ from: "2026-01-01", to: "2026-01-31" }, 7);
|
||||||
|
expect(out).toEqual({ message: "dashboard summary", status: 200, data: { apiUsage: { total: 3 } } });
|
||||||
|
expect(service.summary).toHaveBeenCalledWith(7, { from: "2026-01-01", to: "2026-01-31" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("transactions passes validated options through", async () => {
|
||||||
|
const service = fakeService();
|
||||||
|
const out = await makeDashboardApi(service).transactions({ type: "audit", limit: "5" }, 7);
|
||||||
|
expect(out.message).toBe("dashboard transactions");
|
||||||
|
expect(out.status).toBe(200);
|
||||||
|
expect(service.transactions).toHaveBeenCalledWith(7, expect.objectContaining({ type: "audit", limit: 5 }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("series wraps the combined usage+storage result with the window", async () => {
|
||||||
|
const service = fakeService();
|
||||||
|
const out = await makeDashboardApi(service).series({ from: "2026-01-01", to: "2026-01-31" }, 7);
|
||||||
|
expect(out).toEqual({ message: "dashboard series", status: 200, data: { usage: [], storage: [] } });
|
||||||
|
expect(service.series).toHaveBeenCalledWith(7, { from: "2026-01-01", to: "2026-01-31" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("transactions returns a 400 error payload (not a throw) on invalid input", async () => {
|
||||||
|
const service = fakeService();
|
||||||
|
const out = await makeDashboardApi(service).transactions({ type: "evil" }, 7);
|
||||||
|
expect(out.status).toBe(400);
|
||||||
|
expect(out.data.error).toBeTruthy();
|
||||||
|
expect(service.transactions).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("verify forwards valid event uuids and audit ids, dropping junk", async () => {
|
||||||
|
const service = fakeService();
|
||||||
|
const good = "3fa85f64-5717-4562-b3fc-2c963f66afa6";
|
||||||
|
const out = await makeDashboardApi(service).verify({ ids: `${good},junk`, auditIds: "9,nope" }, 7);
|
||||||
|
expect(out.data.results).toEqual({ eventUuids: [good], auditIds: [9] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("event rejects a non-uuid with 400 and 404s a missing record", async () => {
|
||||||
|
const service = fakeService();
|
||||||
|
const api = makeDashboardApi(service);
|
||||||
|
const bad = await api.event({ eventUuid: "nope" }, 7);
|
||||||
|
expect(bad).toMatchObject({ status: 400, data: { error: "invalid event id" } });
|
||||||
|
|
||||||
|
service.event = jest.fn(async () => null);
|
||||||
|
const missing = await api.event({ eventUuid: "3fa85f64-5717-4562-b3fc-2c963f66afa6" }, 7);
|
||||||
|
expect(missing).toMatchObject({ status: 404, data: { error: "event not found" } });
|
||||||
|
});
|
||||||
|
});
|
||||||
33
backend/modules/dashboard/cache.js
Normal file
33
backend/modules/dashboard/cache.js
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { getPool } from "../../db/index.js";
|
||||||
|
|
||||||
|
// Lockless: concurrent cold misses may both compute, last writer wins.
|
||||||
|
export async function getCached(userId, tag, ttlMs, compute) {
|
||||||
|
let client = await getPool();
|
||||||
|
try {
|
||||||
|
const hit = (
|
||||||
|
await client.query(
|
||||||
|
`SELECT data FROM cache
|
||||||
|
WHERE user_id = $1 AND tag = $2
|
||||||
|
AND updated_ts > NOW() - ($3 * interval '1 millisecond')`,
|
||||||
|
[userId, tag, ttlMs],
|
||||||
|
)
|
||||||
|
).rows[0];
|
||||||
|
if (hit) return hit.data;
|
||||||
|
} finally {
|
||||||
|
await client.release();
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await compute();
|
||||||
|
|
||||||
|
client = await getPool();
|
||||||
|
try {
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO cache (user_id, tag, data) VALUES ($1, $2, $3::jsonb)
|
||||||
|
ON CONFLICT (user_id, tag) DO UPDATE SET data = EXCLUDED.data, updated_ts = NOW()`,
|
||||||
|
[userId, tag, data],
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await client.release();
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
46
backend/modules/dashboard/cache.test.js
Normal file
46
backend/modules/dashboard/cache.test.js
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { jest } from "@jest/globals";
|
||||||
|
|
||||||
|
// Manual DB mock: getPool() returns a fake client whose query() the test drives.
|
||||||
|
const query = jest.fn();
|
||||||
|
const release = jest.fn(async () => {});
|
||||||
|
jest.unstable_mockModule("../../db/index.js", () => ({ getPool: async () => ({ query, release }) }));
|
||||||
|
|
||||||
|
const { getCached } = await import("./cache.js");
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
query.mockReset();
|
||||||
|
release.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getCached", () => {
|
||||||
|
it("returns the cached value on a fresh hit without computing", async () => {
|
||||||
|
query.mockResolvedValueOnce({ rows: [{ data: { v: 1 } }] });
|
||||||
|
const compute = jest.fn(async () => ({ v: 999 }));
|
||||||
|
const out = await getCached(7, "storage", 1000, compute);
|
||||||
|
|
||||||
|
expect(out).toEqual({ v: 1 });
|
||||||
|
expect(compute).not.toHaveBeenCalled();
|
||||||
|
expect(query).toHaveBeenCalledTimes(1); // SELECT only
|
||||||
|
expect(query.mock.calls[0][1]).toEqual([7, "storage", 1000]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("computes and upserts on a miss", async () => {
|
||||||
|
query.mockResolvedValueOnce({ rows: [] }).mockResolvedValueOnce({ rows: [] }); // SELECT miss, then INSERT
|
||||||
|
const compute = jest.fn(async () => ({ v: 2 }));
|
||||||
|
const out = await getCached(7, "storage", 1000, compute);
|
||||||
|
|
||||||
|
expect(out).toEqual({ v: 2 });
|
||||||
|
expect(compute).toHaveBeenCalledTimes(1);
|
||||||
|
expect(query).toHaveBeenCalledTimes(2); // SELECT then INSERT
|
||||||
|
const [sql, params] = query.mock.calls[1];
|
||||||
|
expect(sql).toContain("ON CONFLICT (user_id, tag) DO UPDATE");
|
||||||
|
expect(params).toEqual([7, "storage", { v: 2 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not cache when compute throws", async () => {
|
||||||
|
query.mockResolvedValueOnce({ rows: [] });
|
||||||
|
const compute = jest.fn(async () => { throw new Error("nope"); });
|
||||||
|
await expect(getCached(7, "storage", 1000, compute)).rejects.toThrow("nope");
|
||||||
|
expect(query).toHaveBeenCalledTimes(1); // SELECT only; no INSERT
|
||||||
|
});
|
||||||
|
});
|
||||||
17
backend/modules/dashboard/index.js
Normal file
17
backend/modules/dashboard/index.js
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { dashboardApi } from "./api.js";
|
||||||
|
|
||||||
|
const routes = {
|
||||||
|
'/api/v1/data/dashboard/summary': dashboardApi.summary,
|
||||||
|
'/api/v1/data/dashboard/transactions': dashboardApi.transactions,
|
||||||
|
'/api/v1/data/dashboard/series': dashboardApi.series,
|
||||||
|
'/api/v1/data/dashboard/verify': dashboardApi.verify,
|
||||||
|
'/api/v1/data/dashboard/event': dashboardApi.event,
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function dispatch(url, input, userId) {
|
||||||
|
// hasOwn: a bare lookup resolves Object.prototype members to callables.
|
||||||
|
if (!Object.hasOwn(routes, url)) return null;
|
||||||
|
return routes[url](input, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const dashboard = { dispatch };
|
||||||
79
backend/modules/dashboard/query.js
Normal file
79
backend/modules/dashboard/query.js
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
// The only values ever interpolated into SQL (sort column and direction) come
|
||||||
|
// from the whitelists here; everything else is a bind parameter.
|
||||||
|
|
||||||
|
const SORT_COLUMNS = {
|
||||||
|
agreementUuid: '"agreementUuid"',
|
||||||
|
eventUuid: '"eventUuid"',
|
||||||
|
sender: "sender",
|
||||||
|
date: "date",
|
||||||
|
size: '"size"',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SORTABLE_KEYS = Object.keys(SORT_COLUMNS);
|
||||||
|
|
||||||
|
export function sortClause(sort, dir) {
|
||||||
|
const col = SORT_COLUMNS[sort] || "date";
|
||||||
|
const direction = String(dir).toLowerCase() === "asc" ? "ASC" : "DESC";
|
||||||
|
return `ORDER BY ${col} ${direction} NULLS LAST`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function eventSearchClause(idx) {
|
||||||
|
return (
|
||||||
|
`(e.event_id_uuid::text ILIKE '%'||$${idx}||'%'` +
|
||||||
|
` OR agr.agreement_id_uuid::text ILIKE '%'||$${idx}||'%'` +
|
||||||
|
` OR e.sender_id ILIKE '%'||$${idx}||'%'` +
|
||||||
|
` OR e.recipient_id ILIKE '%'||$${idx}||'%')`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_LIMIT = 50;
|
||||||
|
const MAX_LIMIT = 200;
|
||||||
|
|
||||||
|
// Server-side bound, applied on every request regardless of what the UI sends.
|
||||||
|
export function clampLimit(v) {
|
||||||
|
const n = parseInt(v, 10);
|
||||||
|
if (!Number.isFinite(n)) return DEFAULT_LIMIT;
|
||||||
|
return Math.min(Math.max(n, 1), MAX_LIMIT);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clampOffset(v) {
|
||||||
|
const n = parseInt(v, 10);
|
||||||
|
if (!Number.isFinite(n) || n < 0) return 0;
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pageCount(total, perPage = DEFAULT_LIMIT) {
|
||||||
|
return Math.max(1, Math.ceil((Number(total) || 0) / (perPage || DEFAULT_LIMIT)));
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_WINDOW_DAYS = 366;
|
||||||
|
|
||||||
|
// Enforced server-side on every request, not in the UI.
|
||||||
|
export function windowParams(query = {}) {
|
||||||
|
const iso = (d) => d.toISOString().slice(0, 10);
|
||||||
|
const valid = (s) => typeof s === "string" && /^\d{4}-\d{2}-\d{2}$/.test(s);
|
||||||
|
|
||||||
|
const today = new Date();
|
||||||
|
today.setUTCHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
const to = valid(query.to) ? query.to : iso(today);
|
||||||
|
let from = valid(query.from) ? query.from : null;
|
||||||
|
if (!from) {
|
||||||
|
const d = new Date(`${to}T00:00:00Z`);
|
||||||
|
d.setUTCDate(d.getUTCDate() - 29);
|
||||||
|
from = iso(d);
|
||||||
|
}
|
||||||
|
|
||||||
|
let fromD = new Date(`${from}T00:00:00Z`);
|
||||||
|
const toD = new Date(`${to}T00:00:00Z`);
|
||||||
|
if (fromD > toD) {
|
||||||
|
from = to;
|
||||||
|
fromD = new Date(toD);
|
||||||
|
}
|
||||||
|
if ((toD - fromD) / 86_400_000 > MAX_WINDOW_DAYS) {
|
||||||
|
const capped = new Date(toD);
|
||||||
|
capped.setUTCDate(capped.getUTCDate() - MAX_WINDOW_DAYS);
|
||||||
|
from = iso(capped);
|
||||||
|
}
|
||||||
|
return { from, to };
|
||||||
|
}
|
||||||
107
backend/modules/dashboard/query.test.js
Normal file
107
backend/modules/dashboard/query.test.js
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import {
|
||||||
|
sortClause,
|
||||||
|
SORTABLE_KEYS,
|
||||||
|
eventSearchClause,
|
||||||
|
clampLimit,
|
||||||
|
clampOffset,
|
||||||
|
pageCount,
|
||||||
|
windowParams,
|
||||||
|
DEFAULT_LIMIT,
|
||||||
|
} from "./query.js";
|
||||||
|
|
||||||
|
describe("sortClause", () => {
|
||||||
|
it("maps known keys and directions", () => {
|
||||||
|
expect(sortClause("size", "asc")).toBe('ORDER BY "size" ASC NULLS LAST');
|
||||||
|
expect(sortClause("sender", "desc")).toBe("ORDER BY sender DESC NULLS LAST");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to date DESC for unknown/missing keys", () => {
|
||||||
|
expect(sortClause(undefined, undefined)).toBe("ORDER BY date DESC NULLS LAST");
|
||||||
|
expect(sortClause("nope", "asc")).toBe("ORDER BY date ASC NULLS LAST");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never interpolates an attacker-supplied sort key (injection safety)", () => {
|
||||||
|
const clause = sortClause("date; DROP TABLE event; --", "asc");
|
||||||
|
expect(clause).toBe("ORDER BY date ASC NULLS LAST");
|
||||||
|
expect(clause).not.toMatch(/DROP TABLE/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("only honors exactly 'asc' for direction, otherwise DESC", () => {
|
||||||
|
expect(sortClause("date", "ASC")).toContain("ASC");
|
||||||
|
expect(sortClause("date", "ascending")).toContain("DESC");
|
||||||
|
expect(sortClause("date", "; DELETE")).toContain("DESC");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exposes the sortable keys", () => {
|
||||||
|
expect(SORTABLE_KEYS).toEqual(
|
||||||
|
expect.arrayContaining(["agreementUuid", "eventUuid", "sender", "date", "size"]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("eventSearchClause", () => {
|
||||||
|
it("references the given bind index and only that index (no interpolated value)", () => {
|
||||||
|
const c = eventSearchClause(3);
|
||||||
|
expect(c).toContain("$3");
|
||||||
|
expect(c).toContain("ILIKE");
|
||||||
|
// no other positional params leak in
|
||||||
|
expect(c.match(/\$\d+/g).every((p) => p === "$3")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("clampLimit / clampOffset", () => {
|
||||||
|
it("clamps limit into [1, 200] with a default", () => {
|
||||||
|
expect(clampLimit(undefined)).toBe(DEFAULT_LIMIT);
|
||||||
|
expect(clampLimit("abc")).toBe(DEFAULT_LIMIT);
|
||||||
|
expect(clampLimit("0")).toBe(1);
|
||||||
|
expect(clampLimit("-5")).toBe(1);
|
||||||
|
expect(clampLimit("9999")).toBe(200);
|
||||||
|
expect(clampLimit("75")).toBe(75);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clamps offset to a non-negative integer", () => {
|
||||||
|
expect(clampOffset(undefined)).toBe(0);
|
||||||
|
expect(clampOffset("-3")).toBe(0);
|
||||||
|
expect(clampOffset("abc")).toBe(0);
|
||||||
|
expect(clampOffset("40")).toBe(40);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("pageCount", () => {
|
||||||
|
it("computes ceil(total/perPage), at least 1", () => {
|
||||||
|
expect(pageCount(0, 50)).toBe(1);
|
||||||
|
expect(pageCount(50, 50)).toBe(1);
|
||||||
|
expect(pageCount(51, 50)).toBe(2);
|
||||||
|
expect(pageCount(200, 50)).toBe(4);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("windowParams", () => {
|
||||||
|
it("defaults to a 30-day window ending today (UTC)", () => {
|
||||||
|
const { from, to } = windowParams({});
|
||||||
|
const days = (new Date(`${to}T00:00:00Z`) - new Date(`${from}T00:00:00Z`)) / 86_400_000;
|
||||||
|
expect(days).toBe(29);
|
||||||
|
expect(to).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("honors valid from/to", () => {
|
||||||
|
expect(windowParams({ from: "2026-01-01", to: "2026-01-31" })).toEqual({
|
||||||
|
from: "2026-01-01",
|
||||||
|
to: "2026-01-31",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores invalid dates and collapses inverted ranges", () => {
|
||||||
|
expect(windowParams({ from: "not-a-date", to: "2026-02-10" }).to).toBe("2026-02-10");
|
||||||
|
expect(windowParams({ from: "2026-05-01", to: "2026-01-01" })).toEqual({
|
||||||
|
from: "2026-01-01",
|
||||||
|
to: "2026-01-01",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("caps windows wider than ~a year", () => {
|
||||||
|
const { from, to } = windowParams({ from: "2000-01-01", to: "2026-01-01" });
|
||||||
|
const days = (new Date(`${to}T00:00:00Z`) - new Date(`${from}T00:00:00Z`)) / 86_400_000;
|
||||||
|
expect(days).toBeLessThanOrEqual(366);
|
||||||
|
});
|
||||||
|
});
|
||||||
61
backend/modules/dashboard/series.js
Normal file
61
backend/modules/dashboard/series.js
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { getPool } from "../../db/index.js";
|
||||||
|
|
||||||
|
// Usage counts are per-day; storage totals are running cumulatives.
|
||||||
|
export async function getSeries(userId, { from, to }) {
|
||||||
|
const client = await getPool();
|
||||||
|
try {
|
||||||
|
const res = await client.query(
|
||||||
|
`SELECT
|
||||||
|
to_char(d, 'YYYY-MM-DD') AS date,
|
||||||
|
(
|
||||||
|
SELECT COUNT(*)::int FROM usage u
|
||||||
|
JOIN usage_url uu ON uu.id = u.usage_url_id
|
||||||
|
WHERE u.user_id = $1 AND uu.module = 'core'
|
||||||
|
AND u.created_ts >= d AND u.created_ts < d + interval '1 day'
|
||||||
|
) AS core,
|
||||||
|
(
|
||||||
|
SELECT COUNT(*)::int FROM usage u
|
||||||
|
JOIN usage_url uu ON uu.id = u.usage_url_id
|
||||||
|
WHERE u.user_id = $1 AND uu.module = 'archive'
|
||||||
|
AND u.created_ts >= d AND u.created_ts < d + interval '1 day'
|
||||||
|
) AS audit,
|
||||||
|
(
|
||||||
|
SELECT COALESCE(SUM(pg_column_size(ed.data)), 0)::bigint
|
||||||
|
FROM event e
|
||||||
|
JOIN event_data ed ON ed.event_id = e.id
|
||||||
|
WHERE e.user_id = $1 AND e.created_as_ts < d + interval '1 day'
|
||||||
|
) AS core_disk,
|
||||||
|
(
|
||||||
|
(
|
||||||
|
SELECT COALESCE(SUM(pg_column_size(a.digest)), 0)::bigint
|
||||||
|
FROM audit a
|
||||||
|
JOIN event e ON e.event_id_uuid = a.event_id AND e.user_id = $1
|
||||||
|
WHERE a.created_ts < d + interval '1 day'
|
||||||
|
)
|
||||||
|
+
|
||||||
|
(
|
||||||
|
SELECT COALESCE(SUM(pg_column_size(s.jws)), 0)::bigint
|
||||||
|
FROM audit_signature s
|
||||||
|
JOIN audit a ON a.audit_id = s.audit_id
|
||||||
|
JOIN event e ON e.event_id_uuid = a.event_id AND e.user_id = $1
|
||||||
|
WHERE a.created_ts < d + interval '1 day'
|
||||||
|
)
|
||||||
|
) AS audit_disk
|
||||||
|
FROM generate_series($2::date, $3::date, interval '1 day') AS d
|
||||||
|
ORDER BY d`,
|
||||||
|
[userId, from, to],
|
||||||
|
);
|
||||||
|
|
||||||
|
const usage = [];
|
||||||
|
const storage = [];
|
||||||
|
for (const r of res.rows) {
|
||||||
|
const core = r.core, audit = r.audit;
|
||||||
|
usage.push({ date: r.date, core, audit, total: core + audit });
|
||||||
|
const coreDisk = Number(r.core_disk), auditDisk = Number(r.audit_disk);
|
||||||
|
storage.push({ date: r.date, coreDisk, auditDisk, totalDisk: coreDisk + auditDisk });
|
||||||
|
}
|
||||||
|
return { usage, storage };
|
||||||
|
} finally {
|
||||||
|
await client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
37
backend/modules/dashboard/series.test.js
Normal file
37
backend/modules/dashboard/series.test.js
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { jest } from "@jest/globals";
|
||||||
|
|
||||||
|
const query = jest.fn();
|
||||||
|
const release = jest.fn(async () => {});
|
||||||
|
jest.unstable_mockModule("../../db/index.js", () => ({ getPool: async () => ({ query, release }) }));
|
||||||
|
|
||||||
|
const { getSeries } = await import("./series.js");
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
query.mockReset();
|
||||||
|
release.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getSeries (combined usage + storage, one query)", () => {
|
||||||
|
it("splits one result set into usage counts and storage byte totals with computed totals", async () => {
|
||||||
|
query.mockResolvedValue({
|
||||||
|
rows: [
|
||||||
|
{ date: "2026-06-01", core: 3, audit: 5, core_disk: "100", audit_disk: "50" },
|
||||||
|
{ date: "2026-06-02", core: 0, audit: 0, core_disk: "100", audit_disk: "50" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const out = await getSeries(7, { from: "2026-06-01", to: "2026-06-02" });
|
||||||
|
|
||||||
|
expect(query).toHaveBeenCalledTimes(1); // single round-trip
|
||||||
|
expect(query.mock.calls[0][1]).toEqual([7, "2026-06-01", "2026-06-02"]);
|
||||||
|
expect(out.usage).toEqual([
|
||||||
|
{ date: "2026-06-01", core: 3, audit: 5, total: 8 },
|
||||||
|
{ date: "2026-06-02", core: 0, audit: 0, total: 0 },
|
||||||
|
]);
|
||||||
|
expect(out.storage).toEqual([
|
||||||
|
{ date: "2026-06-01", coreDisk: 100, auditDisk: 50, totalDisk: 150 },
|
||||||
|
{ date: "2026-06-02", coreDisk: 100, auditDisk: 50, totalDisk: 150 },
|
||||||
|
]);
|
||||||
|
expect(release).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
55
backend/modules/dashboard/service.js
Normal file
55
backend/modules/dashboard/service.js
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { getConfig } from "../../common/config.js";
|
||||||
|
import { listCore, listAudit, getEvent, getAgreement, getEventDetails } from "./transactions.js";
|
||||||
|
import { getApiUsage } from "./usage.js";
|
||||||
|
import { getStorage } from "./storage.js";
|
||||||
|
import { getSeries } from "./series.js";
|
||||||
|
import { countApiKeys } from "./account.js";
|
||||||
|
import { getCached } from "./cache.js";
|
||||||
|
import { verifyMany } from "./verify.js";
|
||||||
|
|
||||||
|
// Expensive full per-tenant byte scan that changes slowly.
|
||||||
|
const STORAGE_TTL_MS = 4 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
export async function summary(userId, { from, to }) {
|
||||||
|
const [apiUsage, apiKeys, storage] = await Promise.all([
|
||||||
|
getApiUsage(userId, { from, to }),
|
||||||
|
countApiKeys(userId),
|
||||||
|
getCached(userId, "storage", STORAGE_TTL_MS, () => getStorage(userId)),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
apiUsage,
|
||||||
|
apiKeys,
|
||||||
|
// Externally-facing modules only, not internal ones like `dashboard`.
|
||||||
|
endpoints: Object.values(getConfig().appModules).filter((m) => !m?.internal).length,
|
||||||
|
storage,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function transactions(userId, opts) {
|
||||||
|
const { rows, total } = opts.type === "audit"
|
||||||
|
? await listAudit(userId, opts)
|
||||||
|
: await listCore(userId, opts);
|
||||||
|
return { rows, total, limit: opts.limit, offset: opts.offset };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function series(userId, window) {
|
||||||
|
return getSeries(userId, window);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function event(userId, eventUuid) {
|
||||||
|
return getEvent(userId, eventUuid);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function agreement(userId, agreementUuid) {
|
||||||
|
return getAgreement(userId, agreementUuid);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verify(userId, targets) {
|
||||||
|
return { results: await verifyMany(userId, targets) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function details(userId, eventUuid) {
|
||||||
|
return getEventDetails(userId, eventUuid);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const service = { summary, transactions, series, event, verify, agreement, details };
|
||||||
39
backend/modules/dashboard/storage.js
Normal file
39
backend/modules/dashboard/storage.js
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { getPool } from "../../db/index.js";
|
||||||
|
|
||||||
|
// Audit rows carry no user_id, so they are attributed via event OR agreement
|
||||||
|
// uuid — deliberately wider than the event-only listAudit view.
|
||||||
|
export async function getStorage(userId) {
|
||||||
|
const client = await getPool();
|
||||||
|
try {
|
||||||
|
const row = (
|
||||||
|
await client.query(
|
||||||
|
`WITH ua AS (
|
||||||
|
SELECT a.audit_id, a.digest
|
||||||
|
FROM audit a
|
||||||
|
WHERE a.event_id IN (SELECT event_id_uuid FROM event WHERE user_id = $1)
|
||||||
|
OR a.agreement_id IN (SELECT agreement_id_uuid FROM agreement WHERE user_id = $1)
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
(SELECT COALESCE(SUM(pg_column_size(data)), 0)::bigint FROM event_data WHERE user_id = $1) AS core_on_disk,
|
||||||
|
(SELECT COALESCE(SUM(octet_length(data)), 0)::bigint FROM event_data WHERE user_id = $1) AS core_logical,
|
||||||
|
COALESCE(SUM(pg_column_size(ua.digest)), 0)::bigint
|
||||||
|
+ COALESCE((SELECT SUM(pg_column_size(s.jws)) FROM audit_signature s WHERE s.audit_id IN (SELECT audit_id FROM ua)), 0)::bigint AS audit_on_disk,
|
||||||
|
COALESCE(SUM(octet_length(ua.digest)), 0)::bigint
|
||||||
|
+ COALESCE((SELECT SUM(octet_length(s.jws)) FROM audit_signature s WHERE s.audit_id IN (SELECT audit_id FROM ua)), 0)::bigint AS audit_logical
|
||||||
|
FROM ua`,
|
||||||
|
[userId],
|
||||||
|
)
|
||||||
|
).rows[0];
|
||||||
|
|
||||||
|
const coreOnDisk = Number(row.core_on_disk);
|
||||||
|
const auditOnDisk = Number(row.audit_on_disk);
|
||||||
|
const coreLogical = Number(row.core_logical);
|
||||||
|
const auditLogical = Number(row.audit_logical);
|
||||||
|
return {
|
||||||
|
onDisk: { core: coreOnDisk, audit: auditOnDisk, total: coreOnDisk + auditOnDisk },
|
||||||
|
logical: { core: coreLogical, audit: auditLogical, total: coreLogical + auditLogical },
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
await client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
30
backend/modules/dashboard/storage.test.js
Normal file
30
backend/modules/dashboard/storage.test.js
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { jest } from "@jest/globals";
|
||||||
|
|
||||||
|
const query = jest.fn();
|
||||||
|
const release = jest.fn(async () => {});
|
||||||
|
jest.unstable_mockModule("../../db/index.js", () => ({ getPool: async () => ({ query, release }) }));
|
||||||
|
|
||||||
|
const { getStorage } = await import("./storage.js");
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
query.mockReset();
|
||||||
|
release.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getStorage (core + audit in one query)", () => {
|
||||||
|
it("runs a single query and shapes on-disk/logical core/audit/total byte totals", async () => {
|
||||||
|
query.mockResolvedValue({
|
||||||
|
rows: [{ core_on_disk: "100", core_logical: "120", audit_on_disk: "50", audit_logical: "60" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const out = await getStorage(7);
|
||||||
|
|
||||||
|
expect(query).toHaveBeenCalledTimes(1); // combined round-trip
|
||||||
|
expect(query.mock.calls[0][1]).toEqual([7]);
|
||||||
|
expect(out).toEqual({
|
||||||
|
onDisk: { core: 100, audit: 50, total: 150 },
|
||||||
|
logical: { core: 120, audit: 60, total: 180 },
|
||||||
|
});
|
||||||
|
expect(release).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
248
backend/modules/dashboard/transactions.js
Normal file
248
backend/modules/dashboard/transactions.js
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
import { marked } from "marked";
|
||||||
|
import { getPool } from "../../db/index.js";
|
||||||
|
import { sortClause, eventSearchClause } from "./query.js";
|
||||||
|
|
||||||
|
// Every query is scoped to user_id; the only interpolated SQL is the
|
||||||
|
// whitelisted sort clause. Agreements are deliberately NOT re-scoped by
|
||||||
|
// user_id — an event may reference a public (user_id IS NULL) agreement.
|
||||||
|
export async function listCore(userId, { from, to, q = "", sort, dir, limit = 50, offset = 0 } = {}) {
|
||||||
|
const client = await getPool();
|
||||||
|
try {
|
||||||
|
const params = [userId];
|
||||||
|
let where = `WHERE e.user_id = $1`;
|
||||||
|
if (from && to) {
|
||||||
|
params.push(from, to);
|
||||||
|
// Half-open: includes the whole `to` day. `<= to::date` would drop
|
||||||
|
// everything after midnight of that day.
|
||||||
|
where += ` AND e.created_as_ts >= $${params.length - 1}::date AND e.created_as_ts < ($${params.length}::date + 1)`;
|
||||||
|
}
|
||||||
|
if (q) {
|
||||||
|
params.push(q);
|
||||||
|
where += ` AND ${eventSearchClause(params.length)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = (
|
||||||
|
await client.query(
|
||||||
|
`SELECT COUNT(*)::int AS total
|
||||||
|
FROM event e
|
||||||
|
INNER JOIN agreement agr ON e.agreement_id = agr.id
|
||||||
|
${where}`,
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
).rows[0].total;
|
||||||
|
|
||||||
|
const rows = (
|
||||||
|
await client.query(
|
||||||
|
`SELECT
|
||||||
|
e.event_id_uuid AS "eventUuid",
|
||||||
|
COALESCE(es.short_name, e.sender_id) AS sender,
|
||||||
|
COALESCE(er.short_name, e.recipient_id) AS receiver,
|
||||||
|
e.created_as_ts AS date,
|
||||||
|
agr.agreement_id_uuid AS "agreementUuid",
|
||||||
|
ac.title AS "agreementTitle",
|
||||||
|
octet_length(ed.data::text) AS "size"
|
||||||
|
FROM event e
|
||||||
|
INNER JOIN agreement agr ON e.agreement_id = agr.id
|
||||||
|
LEFT JOIN agreement_content ac ON ac.agreement_uuid = agr.agreement_id_uuid
|
||||||
|
LEFT JOIN event_data ed ON ed.event_id = e.id
|
||||||
|
LEFT JOIN entity es ON es.did_id = e.sender_id AND es.user_id = $1
|
||||||
|
LEFT JOIN entity er ON er.did_id = e.recipient_id AND er.user_id = $1
|
||||||
|
${where}
|
||||||
|
${sortClause(sort, dir)}
|
||||||
|
LIMIT $${params.length + 1} OFFSET $${params.length + 2}`,
|
||||||
|
[...params, limit, offset],
|
||||||
|
)
|
||||||
|
).rows;
|
||||||
|
|
||||||
|
return { rows, total };
|
||||||
|
} finally {
|
||||||
|
await client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// One row per AUDIT RECORD, not per event: `eventUuid` repeats across rows and
|
||||||
|
// `auditId` is what identifies a row uniquely.
|
||||||
|
export async function listAudit(userId, { from, to, q = "", sort, dir, limit = 50, offset = 0 } = {}) {
|
||||||
|
const client = await getPool();
|
||||||
|
try {
|
||||||
|
const params = [userId];
|
||||||
|
let where = `WHERE a.event_id IS NOT NULL`;
|
||||||
|
if (from && to) {
|
||||||
|
params.push(from, to);
|
||||||
|
where += ` AND a.created_ts >= $${params.length - 1}::date AND a.created_ts < ($${params.length}::date + 1)`;
|
||||||
|
}
|
||||||
|
if (q) {
|
||||||
|
params.push(q);
|
||||||
|
where += ` AND ${eventSearchClause(params.length)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseFrom = `
|
||||||
|
FROM audit a
|
||||||
|
INNER JOIN event e ON e.event_id_uuid = a.event_id AND e.user_id = $1
|
||||||
|
INNER JOIN agreement agr ON agr.id = e.agreement_id`;
|
||||||
|
|
||||||
|
const total = (
|
||||||
|
await client.query(`SELECT COUNT(*)::int AS total ${baseFrom} ${where}`, params)
|
||||||
|
).rows[0].total;
|
||||||
|
|
||||||
|
const rows = (
|
||||||
|
await client.query(
|
||||||
|
`SELECT
|
||||||
|
a.audit_id AS "auditId",
|
||||||
|
a.digest AS "auditDigest",
|
||||||
|
e.event_id_uuid AS "eventUuid",
|
||||||
|
COALESCE(es.short_name, e.sender_id) AS sender,
|
||||||
|
COALESCE(er.short_name, e.recipient_id) AS receiver,
|
||||||
|
a.created_ts AS date,
|
||||||
|
agr.agreement_id_uuid AS "agreementUuid",
|
||||||
|
ac.title AS "agreementTitle",
|
||||||
|
(
|
||||||
|
octet_length(a.digest)
|
||||||
|
+ COALESCE((SELECT SUM(octet_length(s.jws)) FROM audit_signature s WHERE s.audit_id = a.audit_id), 0)
|
||||||
|
) AS "size"
|
||||||
|
${baseFrom}
|
||||||
|
LEFT JOIN agreement_content ac ON ac.agreement_uuid = agr.agreement_id_uuid
|
||||||
|
LEFT JOIN entity es ON es.did_id = e.sender_id AND es.user_id = $1
|
||||||
|
LEFT JOIN entity er ON er.did_id = e.recipient_id AND er.user_id = $1
|
||||||
|
${where}
|
||||||
|
${sortClause(sort, dir)}
|
||||||
|
LIMIT $${params.length + 1} OFFSET $${params.length + 2}`,
|
||||||
|
[...params, limit, offset],
|
||||||
|
)
|
||||||
|
).rows;
|
||||||
|
|
||||||
|
return { rows, total };
|
||||||
|
} finally {
|
||||||
|
await client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getEvent(userId, eventUuid) {
|
||||||
|
const client = await getPool();
|
||||||
|
try {
|
||||||
|
const record =
|
||||||
|
(
|
||||||
|
await client.query(
|
||||||
|
`SELECT JSON_BUILD_OBJECT(
|
||||||
|
'version', e.version,
|
||||||
|
'eventId', e.event_id_uuid,
|
||||||
|
'type', et.value,
|
||||||
|
'senderId', e.sender_id,
|
||||||
|
'recipientId', e.recipient_id,
|
||||||
|
'created', e.created,
|
||||||
|
'agreementId', agr.agreement_id_uuid,
|
||||||
|
'data', ed.data
|
||||||
|
) AS record
|
||||||
|
FROM event e
|
||||||
|
JOIN event_type et ON et.id = e.event_type_id
|
||||||
|
JOIN agreement agr ON agr.id = e.agreement_id
|
||||||
|
LEFT JOIN event_data ed ON ed.event_id = e.id
|
||||||
|
WHERE e.user_id = $1 AND e.event_id_uuid = $2`,
|
||||||
|
[userId, eventUuid],
|
||||||
|
)
|
||||||
|
).rows[0]?.record ?? null;
|
||||||
|
|
||||||
|
if (record?.data) {
|
||||||
|
try {
|
||||||
|
record.data = JSON.parse(record.data);
|
||||||
|
} catch {
|
||||||
|
// leave the raw string if it isn't JSON
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return record;
|
||||||
|
} finally {
|
||||||
|
await client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cached agreement markdown rendered to HTML for the "click the agreement" modal.
|
||||||
|
// Scoped to the viewer: their own agreement or a public (user_id IS NULL) one.
|
||||||
|
export async function getAgreement(userId, agreementUuid) {
|
||||||
|
const client = await getPool();
|
||||||
|
try {
|
||||||
|
const row = (
|
||||||
|
await client.query(
|
||||||
|
`SELECT title, markdown FROM agreement_content
|
||||||
|
WHERE agreement_uuid = $1 AND (user_id = $2 OR user_id IS NULL)
|
||||||
|
LIMIT 1`,
|
||||||
|
[agreementUuid, userId],
|
||||||
|
)
|
||||||
|
).rows[0];
|
||||||
|
if (!row) return null;
|
||||||
|
return { title: row.title, html: marked(row.markdown || "") };
|
||||||
|
} finally {
|
||||||
|
await client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getEventDetails(userId, eventUuid) {
|
||||||
|
const client = await getPool();
|
||||||
|
try {
|
||||||
|
const event = await getEvent(userId, eventUuid);
|
||||||
|
if (!event) return null;
|
||||||
|
|
||||||
|
const result = await client.query(`
|
||||||
|
SELECT s.signer_id, r.value AS role
|
||||||
|
FROM signature s
|
||||||
|
JOIN agreement agr ON agr.id = s.agreement_id
|
||||||
|
LEFT JOIN role r ON r.id = s.role_id
|
||||||
|
WHERE agr.agreement_id_uuid = $1
|
||||||
|
`, [event.agreementId])
|
||||||
|
|
||||||
|
const roleByDid = {};
|
||||||
|
|
||||||
|
for (const row of result.rows) {
|
||||||
|
roleByDid[row.signer_id] = row.role;
|
||||||
|
}
|
||||||
|
|
||||||
|
const roles = {
|
||||||
|
sender: roleByDid[event.senderId] || null,
|
||||||
|
receiver: roleByDid[event.recipientId] || null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the sender/receiver DIDs to their entity short names (user@fedid…).
|
||||||
|
const nameResult = await client.query(`
|
||||||
|
SELECT did_id, short_name FROM entity
|
||||||
|
WHERE user_id = $1 AND did_id = ANY($2::text[])
|
||||||
|
`, [userId, [event.senderId, event.recipientId]])
|
||||||
|
const nameByDid = {}
|
||||||
|
for (const row of nameResult.rows) nameByDid[row.did_id] = row.short_name
|
||||||
|
const names = {
|
||||||
|
sender: nameByDid[event.senderId] || null,
|
||||||
|
receiver: nameByDid[event.recipientId] || null
|
||||||
|
}
|
||||||
|
|
||||||
|
const permittedResult = await client.query(`
|
||||||
|
SELECT p.value FROM purpose p
|
||||||
|
INNER JOIN agreement_purpose ap
|
||||||
|
ON p.id = ap.purpose_id
|
||||||
|
INNER JOIN agreement a ON ap.agreement_id = a.id
|
||||||
|
WHERE a.agreement_id_uuid = $1
|
||||||
|
`, [event.agreementId])
|
||||||
|
|
||||||
|
const permitted = permittedResult.rows.map(row => row.value);
|
||||||
|
|
||||||
|
const prohibitedResult = await client.query(`
|
||||||
|
SELECT p.value FROM prohibition p
|
||||||
|
INNER JOIN agreement_prohibition aph
|
||||||
|
ON p.id = aph.prohibition_id
|
||||||
|
INNER JOIN agreement a ON aph.agreement_id = a.id
|
||||||
|
WHERE a.agreement_id_uuid = $1
|
||||||
|
`, [event.agreementId])
|
||||||
|
const prohibited = prohibitedResult.rows.map(row => row.value)
|
||||||
|
|
||||||
|
const title = (await client.query(`
|
||||||
|
SELECT title FROM agreement_content
|
||||||
|
WHERE agreement_uuid = $1
|
||||||
|
`, [event.agreementId])).rows[0]?.title ?? null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
event,
|
||||||
|
roles,
|
||||||
|
names,
|
||||||
|
agreement: {permitted, prohibited, title}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user