Compare commits
4
Commits
0622018d95
...
11ad7bfbeb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
11ad7bfbeb | ||
|
|
9099ddeb78 | ||
|
|
a239a76cc7 | ||
|
|
bf59249ed7 |
No files matched your search
+3
-1
@@ -1,3 +1,5 @@
|
||||
/data
|
||||
src/node_modules
|
||||
/scripts
|
||||
backend/node_modules
|
||||
frontend/node_modules
|
||||
docker-compose.*.yml
|
||||
+12
@@ -1,4 +1,16 @@
|
||||
/data
|
||||
/scripts
|
||||
/backend/ui
|
||||
backend/http/public/favicon.ico
|
||||
backend/http/public/favicon.svg
|
||||
backend/http/public/_astro
|
||||
backend/http/public/fonts
|
||||
node_modules
|
||||
packages
|
||||
docker-compose.*.yml
|
||||
|
||||
# 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/
|
||||
+14
-3
@@ -1,6 +1,17 @@
|
||||
FROM node:25
|
||||
ADD src/package*.json /app/
|
||||
FROM node:25 AS builder
|
||||
ADD frontend/package*.json /app/
|
||||
WORKDIR /app
|
||||
RUN npm install
|
||||
ADD src /app
|
||||
ADD frontend /app
|
||||
RUN npm run build
|
||||
|
||||
FROM node:25
|
||||
ADD backend/package*.json /app/
|
||||
WORKDIR /app
|
||||
RUN npm install
|
||||
ADD backend /app
|
||||
COPY --from=builder /app/dist/index.html /app/ui/
|
||||
COPY --from=builder /app/dist/favicon.* /app/http/public/
|
||||
COPY --from=builder /app/dist/_astro /app/http/public/_astro
|
||||
COPY --from=builder /app/dist/fonts /app/http/public/fonts
|
||||
CMD ["npm", "start"]
|
||||
File renamed without changes.
@@ -6,6 +6,8 @@ export async function loadApps() {
|
||||
const config = getConfig();
|
||||
const client = await getPool();
|
||||
for (const type in config.appModules) {
|
||||
// Internal modules (e.g. dashboard) have no `app` row / API surface.
|
||||
if (config.appModules[type]?.internal) continue;
|
||||
await client.query(`
|
||||
INSERT INTO public.app (
|
||||
type
|
||||
@@ -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 = {
|
||||
debug: false,
|
||||
archiveList: true,
|
||||
dashboard: { core: {}, audit: {}, keyCache: { ...DEFAULT_KEY_CACHE } },
|
||||
};
|
||||
|
||||
export async function loadConfig() {
|
||||
@@ -19,6 +23,7 @@ export async function loadConfig() {
|
||||
config.authModules[type] = getModuleConfig();
|
||||
}
|
||||
}
|
||||
if (process.env.ARCHIVE_LIST) config.archiveList = process.env.ARCHIVE_LIST;
|
||||
config.appModules = {};
|
||||
let appModules = [
|
||||
'core',
|
||||
@@ -35,6 +40,30 @@ export async function loadConfig() {
|
||||
if (process.env.PDP_TYPE) config.pdpType = process.env.PDP_TYPE;
|
||||
if (process.env.PDP_URL) config.pdpUrl = process.env.PDP_URL;
|
||||
if (process.env.INSTANT_QUEUE) config.instantQueue = process.env.INSTANT_QUEUE;
|
||||
|
||||
// Remote agreement-markdown caching: exponential backoff for failed fetches
|
||||
// so an invalid URL isn't retried on every referencing create. Defaults:
|
||||
// 1 minute → 1 day, factor 2, give up after 10 attempts. All overridable.
|
||||
config.agreementCache = {
|
||||
startingDelayMs: Number(process.env.AGREEMENT_CACHE_STARTING_DELAY_MS) || 60_000,
|
||||
maxDelayMs: Number(process.env.AGREEMENT_CACHE_MAX_DELAY_MS) || 86_400_000,
|
||||
maxRetries: Number(process.env.AGREEMENT_CACHE_MAX_RETRIES) || 10,
|
||||
factor: Number(process.env.AGREEMENT_CACHE_BACKOFF_FACTOR) || 2,
|
||||
};
|
||||
|
||||
// Optional federation: source dashboard core/audit data from remote servers
|
||||
// (via their /api/v1/data/dashboard/* API + an API key) instead of locally.
|
||||
// Unset => serve from the local DB (the single-operator default).
|
||||
const keyCacheTtl = Number(process.env.DASHBOARD_KEY_CACHE_TTL_MS);
|
||||
const keyCacheMax = Number(process.env.DASHBOARD_KEY_CACHE_MAX);
|
||||
config.dashboard = {
|
||||
core: { url: process.env.DASHBOARD_CORE_URL || null, key: process.env.DASHBOARD_CORE_KEY || null },
|
||||
audit: { url: process.env.DASHBOARD_AUDIT_URL || null, key: process.env.DASHBOARD_AUDIT_KEY || null },
|
||||
keyCache: {
|
||||
ttlMs: Number.isFinite(keyCacheTtl) ? keyCacheTtl : DEFAULT_KEY_CACHE.ttlMs,
|
||||
max: Number.isFinite(keyCacheMax) && keyCacheMax > 0 ? keyCacheMax : DEFAULT_KEY_CACHE.max,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function getConfig() {
|
||||
@@ -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");
|
||||
}
|
||||
};
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -6,7 +6,12 @@ const { Pool } = pkg;
|
||||
|
||||
import { sleep } from "../common/sleep.js";
|
||||
import { getConfig } from "../common/config.js";
|
||||
import { data } from "../modules/core/data/index.js";
|
||||
import { firstNonBlankLine } from "../util/firstNonBlankLine.js";
|
||||
|
||||
// NOTE: the domain `data` layer (which pulls in @jlinc/core) is imported lazily
|
||||
// inside populateAgreements() only. Keeping it out of this module's top-level
|
||||
// imports lets the pool/migrate code — and anything that just needs getPool() —
|
||||
// load without dragging in the crypto engine.
|
||||
|
||||
let pool;
|
||||
|
||||
@@ -125,6 +130,7 @@ export async function migrate() {
|
||||
export async function populateAgreements() {
|
||||
console.log(`Starting agreement population`);
|
||||
const config = getConfig();
|
||||
const { data } = await import("../modules/core/data/index.js");
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
const files = fs.readdirSync("./db/agreements", {
|
||||
@@ -134,9 +140,9 @@ export async function populateAgreements() {
|
||||
if (file.name.endsWith('.json'))
|
||||
continue;
|
||||
const agreementUuid = file.name.slice(0, 36);
|
||||
const title = file.name.slice(39, file.name.length - 3);
|
||||
const markdown = fs.readFileSync(path.join("./db/agreements", file.name), "utf8").trim();
|
||||
const json = JSON.parse(fs.readFileSync(path.join("./db/agreements", file.name.replace('.md', '.json')), "utf8").trim());
|
||||
const title = firstNonBlankLine(markdown);
|
||||
const hash = createHash('sha256')
|
||||
.update(markdown)
|
||||
.digest('hex')
|
||||
@@ -156,6 +162,8 @@ export async function populateAgreements() {
|
||||
);
|
||||
if (!agreementExists.rows[0].exists) {
|
||||
console.log(`Adding agreement '${title}' (${agreementUuid})`);
|
||||
// Agreement content is immutable once inserted (it may already be
|
||||
// signed), so this is INSERT-only — never an upsert.
|
||||
await client.query(`
|
||||
INSERT INTO agreement_content (
|
||||
title,
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -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 });
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -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 { getPool } from "../db/index.js";
|
||||
import { verifyKey } from "../modules/core/apiKey.js";
|
||||
import crypto from 'crypto';
|
||||
|
||||
// Bearer-token auth for the central /api/v1/* API. On success it sets
|
||||
// req.session.user_id (which the core router scopes queries by). It does NOT
|
||||
// populate req.user — the browser session routes (passport) own that — so the
|
||||
// session-authed dashboard/key-management routes remain login-only by design.
|
||||
export async function apiMiddleware(req, res, next) {
|
||||
let success = false;
|
||||
const client = await getPool();
|
||||
try {
|
||||
const config = getConfig();
|
||||
const authHeader = req.headers['authorization'];
|
||||
if (authHeader) {
|
||||
const apiKey = authHeader.split(' ')[1];
|
||||
if (apiKey) {
|
||||
const validRes = await client.query(`
|
||||
SELECT
|
||||
au.user_id,
|
||||
ap.type
|
||||
FROM public.auth au
|
||||
INNER JOIN public.app ap ON ap.id = au.app_id
|
||||
WHERE au.api_key = $1
|
||||
`, [
|
||||
apiKey,
|
||||
]);
|
||||
if (validRes.rowCount > 0) {
|
||||
// if (config.debug) console.log({apiKey});
|
||||
req.session.user_id = validRes.rows[0].user_id;
|
||||
success = true
|
||||
const apiKey = req.headers['authorization']?.split(' ')[1];
|
||||
if (apiKey) {
|
||||
// Preferred: the hashed multi-key store (with a short in-memory cache).
|
||||
const found = await verifyKey(apiKey);
|
||||
if (found) {
|
||||
req.session.user_id = found.user_id;
|
||||
success = true;
|
||||
} else {
|
||||
// Fallback: the legacy single plaintext key in `auth` (issued at login).
|
||||
const client = await getPool();
|
||||
try {
|
||||
const r = await client.query(
|
||||
`SELECT au.user_id FROM public.auth au WHERE au.api_key = $1`,
|
||||
[apiKey],
|
||||
);
|
||||
if (r.rowCount > 0) {
|
||||
req.session.user_id = r.rows[0].user_id;
|
||||
success = true;
|
||||
}
|
||||
} finally {
|
||||
await client.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
await client.release();
|
||||
}
|
||||
if (!success)
|
||||
return res.status(401).json({ error: 'API key is invalid' });
|
||||
@@ -48,6 +52,8 @@ export function getNewKey(user) {
|
||||
async function createApiKeys(client, user, issuer) {
|
||||
const config = getConfig();
|
||||
for (const type in config.appModules) {
|
||||
// Internal modules (e.g. dashboard) have no `app` row and mint no keys.
|
||||
if (config.appModules[type]?.internal) continue;
|
||||
const apiKey = issuer !== 'https://single'
|
||||
? getNewKey(user)
|
||||
: config.authModules.single[type]
|
||||
@@ -93,6 +99,7 @@ export async function getUser(client, issuer, identifier) {
|
||||
u.photo,
|
||||
u.issuer,
|
||||
u.identifier,
|
||||
COALESCE(ut.can_view_json, FALSE) AS "canViewJson",
|
||||
json_agg(
|
||||
jsonb_build_object(
|
||||
'id', a.id,
|
||||
@@ -103,6 +110,7 @@ export async function getUser(client, issuer, identifier) {
|
||||
FROM public.user u
|
||||
INNER JOIN public.auth au ON u.id = au.user_id
|
||||
INNER JOIN public.app a ON au.app_id = a.id
|
||||
LEFT JOIN public.user_type ut ON ut.id = u.user_type_id
|
||||
WHERE u.identifier = $1
|
||||
AND u.issuer = $2
|
||||
GROUP BY
|
||||
@@ -110,7 +118,8 @@ export async function getUser(client, issuer, identifier) {
|
||||
u.username,
|
||||
u.photo,
|
||||
u.issuer,
|
||||
u.identifier
|
||||
u.identifier,
|
||||
ut.can_view_json
|
||||
`,
|
||||
[
|
||||
identifier,
|
||||
@@ -130,19 +139,24 @@ export async function checkUser(type, issuer, identifier, username, photo) {
|
||||
if (!username || username === '') {
|
||||
username = identifier;
|
||||
}
|
||||
// New users default to the least-privileged role; an administrator
|
||||
// promotes them. Existing users keep whatever the DB already holds
|
||||
// (see the 000019 backfill) — checkUser never overwrites an existing type.
|
||||
const res = await client.query(`
|
||||
INSERT INTO public.user (
|
||||
username,
|
||||
issuer,
|
||||
identifier,
|
||||
photo,
|
||||
type
|
||||
type,
|
||||
user_type_id
|
||||
) VALUES (
|
||||
$1,
|
||||
$2,
|
||||
$3,
|
||||
$4,
|
||||
$5
|
||||
$5,
|
||||
(SELECT id FROM public.user_type WHERE value = 'standard')
|
||||
) RETURNING id;
|
||||
`, [
|
||||
username,
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
})
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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))
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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 swaggerDocument from "./api/v1/swagger.json" with { type: "json" };
|
||||
import { getConfig } from "../common/config.js";
|
||||
import { core } from "../modules/core/index.js"
|
||||
import { getAgreements } from "../http/agreements.js"
|
||||
import { core } from "../modules/core/index.js";
|
||||
import { getAgreements } from "../http/agreements.js";
|
||||
import { getArchiveServers } from "../common/archive.js";
|
||||
|
||||
import express from "express";
|
||||
import session from "express-session";
|
||||
import MemoryStore from "memorystore";
|
||||
import { PgSessionStore } from "./sessionStore.js";
|
||||
import passport from "passport";
|
||||
import { refresh } from "./refresh.js";
|
||||
import { logout } from "./logout.js";
|
||||
import { logRequest } from "./logging.js";
|
||||
import { routeAgreements } from "./agreements.js";
|
||||
import { dashboardHandlers } from "./dashboard.js";
|
||||
import { makePassthrough } from "./dashboardPassthrough.js";
|
||||
import { apiKeyHandlers } from "./apiKeys.js";
|
||||
import { getUsage } from "../modules/core/usage.js";
|
||||
import path from "path";
|
||||
|
||||
async function render(view, res, config) {
|
||||
try {
|
||||
@@ -48,8 +53,21 @@ async function renderPrivate(view, req, res, config) {
|
||||
}
|
||||
}
|
||||
|
||||
async function renderPrivateUI(req, res, config) {
|
||||
try {
|
||||
if (!req.user) {
|
||||
return res.redirect('/');
|
||||
}
|
||||
const uiPath = path.join(process.cwd(), "ui", "index.html");
|
||||
res.sendFile(uiPath);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
export async function initHTTP(app) {
|
||||
const config = getConfig();
|
||||
const archiveServers = await getArchiveServers(config);
|
||||
|
||||
passport.serializeUser(function (user, done) {
|
||||
done(null, user);
|
||||
@@ -58,19 +76,16 @@ export async function initHTTP(app) {
|
||||
done(null, user);
|
||||
});
|
||||
|
||||
const memoryStore = MemoryStore(session);
|
||||
const sess = {
|
||||
secret: config.secureSecret,
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
store: new memoryStore({
|
||||
checkPeriod: 86400000 // prune expired entries every 24h
|
||||
}),
|
||||
cookie: {},
|
||||
store: new PgSessionStore(),
|
||||
cookie: { httpOnly: true, sameSite: 'lax' }, // sameSite=lax: CSRF defense
|
||||
}
|
||||
if (app.get('env') === 'production') {
|
||||
app.set('trust proxy', 1) // trust first proxy
|
||||
sess.cookie.secure = true // serve secure cookies
|
||||
sess.cookie.secure = true // serve secure cookies over HTTPS
|
||||
}
|
||||
app.use(session(sess));
|
||||
app.use(passport.initialize());
|
||||
@@ -91,7 +106,27 @@ export async function initHTTP(app) {
|
||||
app.get("/refresh", refresh);
|
||||
app.post('/logout', logout);
|
||||
|
||||
// UI
|
||||
app.get("/home", (req, res) => renderPrivateUI(req, res, config));
|
||||
|
||||
const dashboard = dashboardHandlers({ service: makePassthrough() });
|
||||
app.get("/api/dashboard/summary", dashboard.summary);
|
||||
app.get("/api/dashboard/transactions", dashboard.transactions);
|
||||
app.get("/api/dashboard/series", dashboard.series);
|
||||
app.get("/api/dashboard/verify", dashboard.verify);
|
||||
app.get("/api/dashboard/transactions/:eventUuid", dashboard.event);
|
||||
app.get("/api/dashboard/agreement/:agreementUuid", dashboard.agreement);
|
||||
app.get("/api/dashboard/details/:eventUuid", dashboard.details);
|
||||
|
||||
|
||||
const apiKeys = apiKeyHandlers();
|
||||
app.get("/api/dashboard/auth/whoami", apiKeys.whoami);
|
||||
app.get("/api/dashboard/auth/keys", apiKeys.list);
|
||||
app.post("/api/dashboard/auth/keys", express.json(), apiKeys.create);
|
||||
app.delete("/api/dashboard/auth/keys/:id", apiKeys.revoke);
|
||||
|
||||
app.post(/^\/api\/v1\/.*$/, bodyParser.json(), apiMiddleware, core.post);
|
||||
|
||||
// app.use("/api/v1", swaggerUi.serve, swaggerUi.setup(swaggerDocument));
|
||||
}
|
||||
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
File renamed without changes.
@@ -4,6 +4,7 @@
|
||||
<%- include('./include/header.ejs', { title: 'JLINC - Dashboard' }) %>
|
||||
|
||||
<% for (const type in config.appModules) { %>
|
||||
<% if (config.appModules[type].internal) { continue; } %>
|
||||
<%-
|
||||
include('./include/app.ejs', {
|
||||
app: config.appModules[type],
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -3,6 +3,7 @@ import { init, close, migrate, populateAgreements } from "./db/index.js";
|
||||
import { loadConfig } from "./common/config.js";
|
||||
import { loadApps } from "./apps.js";
|
||||
import { initHTTP } from "./http/index.js";
|
||||
import { migrateLegacyApiKeys } from "./modules/core/apiKey.js";
|
||||
|
||||
const app = express();
|
||||
app.set("env", "development");
|
||||
@@ -15,6 +16,8 @@ async function main() {
|
||||
await populateAgreements();
|
||||
await loadApps();
|
||||
await initHTTP(app);
|
||||
// After initHTTP so the auth rows (incl. the single/Docker user's) exist.
|
||||
await migrateLegacyApiKeys();
|
||||
const server = await app.listen(9090, () => {
|
||||
console.log(`Listening on 0.0.0.0:9090`);
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
File renamed without changes.
File renamed without changes.
@@ -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
|
||||
}),
|
||||
function (req, res) {
|
||||
res.redirect('/dashboard');
|
||||
res.redirect('/home');
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -40,7 +40,7 @@ export async function initModule(app, passport) {
|
||||
failureMessage: true
|
||||
}),
|
||||
function (req, res) {
|
||||
res.redirect('/dashboard');
|
||||
res.redirect('/home');
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -7,7 +7,7 @@ const key = 'oidc';
|
||||
export function getModuleConfig() {
|
||||
const config = getConfig();
|
||||
return {
|
||||
title: 'FedID',
|
||||
title: process.env.OIDC_BUTTON_LABEL || 'FedID',
|
||||
icon: 'login',
|
||||
issuer: process.env.OIDC_ISSUER,
|
||||
authorizationURL: process.env.OIDC_AUTHORIZATION_URL,
|
||||
@@ -46,7 +46,7 @@ export async function initModule(app, passport) {
|
||||
}),
|
||||
function (req, res) {
|
||||
req.session.authStrategy = 'oidc';
|
||||
res.redirect('/dashboard');
|
||||
res.redirect('/home');
|
||||
}
|
||||
);
|
||||
}
|
||||
File renamed without changes.
File renamed without changes.
@@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
File renamed without changes.
File renamed without changes.
@@ -3,6 +3,7 @@ const { JlincAgreement, JlincAudit } = pkg;
|
||||
import { getPool } from "../../../db/index.js";
|
||||
import { entity } from "./entity.js";
|
||||
import { putQueue } from "../../../common/queue.js";
|
||||
import { cacheAgreementMarkdown } from "../../../util/cacheAgreement.js";
|
||||
|
||||
async function getAgreement(client, userId, id, key) {
|
||||
const whereClause = id ? `a.agreement_id_uuid = $1` : `a.agreement_id_uuid = (SELECT agreement_uuid FROM agreement_content WHERE lower(key) = lower($1))`;
|
||||
@@ -439,6 +440,9 @@ async function create(input, userId, _client, uuid) {
|
||||
agreement.agreementId = uuid;
|
||||
}
|
||||
await save(client, userId, agreement, input.public);
|
||||
for (const uri of (agreement.references || [])) {
|
||||
await cacheAgreementMarkdown({ userId, agreementUuid: agreement.agreementId, uri }, client);
|
||||
}
|
||||
response = {
|
||||
message: `created and saved: ${agreement.agreementId}`,
|
||||
data: agreement,
|
||||
@@ -134,9 +134,11 @@ async function verify(input, userId) {
|
||||
valid: [],
|
||||
invalid: [],
|
||||
};
|
||||
input.didDocs = [];
|
||||
for (const shortName of input.shortNames) {
|
||||
input.didDocs.push((await entity.getEntity(client, userId, shortName)).didDoc)
|
||||
if (!input.didDocs) {
|
||||
input.didDocs = [];
|
||||
for (const shortName of input.shortNames || []) {
|
||||
input.didDocs.push((await entity.getEntity(client, userId, shortName)).didDoc)
|
||||
}
|
||||
}
|
||||
delete input.shortNames;
|
||||
const organizedById = [];
|
||||
File renamed without changes.
@@ -3,6 +3,7 @@ const { JlincEvent, JlincAudit } = pkg;
|
||||
import { getPool } from "../../../db/index.js";
|
||||
import { entity } from "./entity.js";
|
||||
import { putQueue } from "../../../common/queue.js";
|
||||
import { cacheAgreementMarkdown } from "../../../util/cacheAgreement.js";
|
||||
|
||||
async function getEvent(client, userId, id, includeData, meta) {
|
||||
const dataSql = includeData
|
||||
@@ -286,6 +287,12 @@ async function create(input, userId, _client, _sender) {
|
||||
delete input.recipientShortName
|
||||
const event = await JlincEvent.create(input);
|
||||
await save(client, userId, event, input.meta);
|
||||
// Pull down any referenced agreement content now (request refs are URIs,
|
||||
// permission refs are { reference, permitted }), so a get never has to.
|
||||
for (const ref of (event.references || [])) {
|
||||
const uri = typeof ref === "string" ? ref : ref?.reference;
|
||||
await cacheAgreementMarkdown({ userId, agreementUuid: event.agreementId, uri }, client);
|
||||
}
|
||||
response = {
|
||||
message: `created and saved: ${event.eventId}`,
|
||||
data: event,
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -6,8 +6,29 @@ import { data } from "./data/index.js";
|
||||
import { archive } from "./archive.js";
|
||||
import { trackUsage } from "./usage.js";
|
||||
import { getConfig } from "../../common/config.js";
|
||||
import { dashboard } from "../dashboard/index.js";
|
||||
import { evaluate } from "../pep/index.js";
|
||||
|
||||
// The event/agreement UUIDs a request touched, so a usage row can link back to
|
||||
// them. Checks the known request/response shapes in priority order; non-core
|
||||
// calls (DID ops, etc.) have neither and fall through to null.
|
||||
function pickUsageIds(input, response) {
|
||||
const eventId =
|
||||
input?.eventId ??
|
||||
response?.eventId ??
|
||||
response?.event?.eventId ??
|
||||
response?.created?.eventId ??
|
||||
null;
|
||||
const agreementId =
|
||||
input?.agreementId ??
|
||||
response?.agreementId ??
|
||||
response?.agreement?.agreementId ??
|
||||
response?.event?.agreementId ??
|
||||
response?.created?.agreementId ??
|
||||
null;
|
||||
return { eventId, agreementId };
|
||||
}
|
||||
|
||||
async function post(req, res) {
|
||||
let response = {
|
||||
success: false,
|
||||
@@ -155,11 +176,20 @@ async function post(req, res) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!type) {
|
||||
response.error = 'Page not found';
|
||||
errorCode = 404;
|
||||
}
|
||||
// Dashboard is its own app module: read-only console data, dispatched
|
||||
// separately from core and typed 'dashboard' so it never counts as usage.
|
||||
if (evaluation && !type && Object.keys(config.appModules).includes('dashboard')) {
|
||||
const dashboardResponse = await dashboard.dispatch(req.url, input, req.session.user_id);
|
||||
if (dashboardResponse) {
|
||||
response = dashboardResponse;
|
||||
type = 'dashboard';
|
||||
}
|
||||
}
|
||||
if (evaluation && !type) {
|
||||
response.error = 'Page not found';
|
||||
errorCode = 404;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
response.error = e.message;
|
||||
@@ -185,7 +215,11 @@ async function post(req, res) {
|
||||
req.apiMessage = response.message;
|
||||
res.status(200).json(response.data);
|
||||
}
|
||||
await trackUsage(req.session.user_id, req.url, type, response?.error ? false : true);
|
||||
// Dashboard calls are console reads, not billable API usage — don't track them.
|
||||
if (type !== 'dashboard') {
|
||||
const { eventId, agreementId } = pickUsageIds(req.body, response);
|
||||
await trackUsage(req.session.user_id, req.url, type, response?.error ? false : true, eventId, agreementId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { getPool } from "../../db/index.js";
|
||||
|
||||
export async function trackUsage(user_id, url, type, success) {
|
||||
export async function trackUsage(user_id, url, type, success, eventId = null, agreementId = null) {
|
||||
const client = await getPool();
|
||||
try {
|
||||
await client.query(`
|
||||
@@ -19,7 +19,9 @@ export async function trackUsage(user_id, url, type, success) {
|
||||
INSERT INTO usage (
|
||||
user_id,
|
||||
usage_url_id,
|
||||
success
|
||||
success,
|
||||
event_id_uuid,
|
||||
agreement_id_uuid
|
||||
) VALUES (
|
||||
$1,
|
||||
(
|
||||
@@ -27,12 +29,16 @@ export async function trackUsage(user_id, url, type, success) {
|
||||
FROM usage_url
|
||||
WHERE url = $2
|
||||
),
|
||||
$3
|
||||
$3,
|
||||
$4,
|
||||
$5
|
||||
);
|
||||
`, [
|
||||
user_id,
|
||||
url,
|
||||
success,
|
||||
eventId,
|
||||
agreementId,
|
||||
]);
|
||||
} catch(e) {
|
||||
console.error(e)
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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" } });
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Loaded 100 of 164 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user