addition of new dashboard

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

View File

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

View File

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

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

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

View File

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

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

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

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

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

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

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

View File

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

View File

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

View File

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

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

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

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

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

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

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

View File

@@ -0,0 +1,18 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100.47 100.47">
<defs>
<style>.cls-1{fill:#fff;}.cls-2{fill:#31a9ba;}</style>
</defs>
<title>JLINC Icon</title>
<g id="Layer_2" data-name="Layer 2">
<g id="Layer_1-2" data-name="Layer 1">
<path class="cls-2"
d="M8.52,41.22l9,9L36.46,69.16l7.76-7.76L25.3,42.48l-9.42-9.42q-4.5-4.52-5.14-9.26c-.48-3.11,1-6.36,4.35-9.73Q19.22,9.95,23.64,10t9.26,4.83l4,4,7.2-7.21L38.52,6a20.93,20.93,0,0,0-7.91-4.9A17.1,17.1,0,0,0,20,.61Q14,2,8.05,8a28.56,28.56,0,0,0-6.33,9.5,18.2,18.2,0,0,0-.79,11.4Q2.34,35,8.52,41.22Z" />
<path class="cls-2"
d="M61.4,56.25,42.48,75.17l-9.42,9.42q-4.52,4.51-9.26,5.14t-9.73-4.35Q9.95,81.27,10,76.83t4.83-9.26l4-3.95-7.21-7.21L6,62a21,21,0,0,0-4.9,7.92A17.09,17.09,0,0,0,.61,80.48q1.43,6,7.36,12a28.87,28.87,0,0,0,9.5,6.33,18.2,18.2,0,0,0,11.4.79Q35,98.13,41.22,92l9-9L69.16,64Z" />
<path class="cls-2"
d="M92,59.26l-9-9L64,31.31l-7.76,7.76L75.17,58l9.42,9.42q4.51,4.52,5.14,9.26t-4.35,9.74c-2.74,2.74-5.59,4.12-8.55,4.11s-6-1.55-9.26-4.83l-3.95-4-7.21,7.2L62,94.48a21.11,21.11,0,0,0,7.92,4.91,17.06,17.06,0,0,0,10.6.47q6-1.42,12-7.36A28.87,28.87,0,0,0,98.76,83a18.16,18.16,0,0,0,.79-11.39Q98.13,65.43,92,59.26Z" />
<path class="cls-2"
d="M39.07,44.22,58,25.3l9.42-9.42q4.52-4.5,9.26-5.14c3.12-.48,6.36,1,9.74,4.35,2.74,2.75,4.11,5.59,4.11,8.55s-1.55,6-4.83,9.26l-4,4,7.2,7.2,5.54-5.54a21.07,21.07,0,0,0,4.91-7.91A17.17,17.17,0,0,0,99.86,20Q98.44,14,92.5,8.05A28.56,28.56,0,0,0,83,1.72,18.19,18.19,0,0,0,71.6.93Q65.44,2.34,59.26,8.52l-9,9L31.31,36.46Z" />
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 362.08 100.47"><defs><style>.cls-1{fill:#fff;}.cls-2{fill:#31a9ba;}</style></defs><title>JLINC Logo H White</title><g id="Layer_2" data-name="Layer 2"><g id="Layer_1-2" data-name="Layer 1"><path class="cls-1" d="M157.32,29.36h8.35V57.2q0,13.9-13.92,13.91H137.84q-14.22,0-14-13.91l0-1.4h8.35v.25q0,6.72,6.68,6.71h11.66q6.7,0,6.71-6.73Z"/><path class="cls-1" d="M223.12,62.76v8.35H181.37V29.36h8.35v33.4Z"/><path class="cls-1" d="M247.18,71.11h-8.35V29.36h8.35Z"/><path class="cls-1" d="M271.23,42V71.11h-8.35V29.36h8.51l24.89,29.09V29.36h8.35V71.11h-8.49Z"/><path class="cls-1" d="M362.08,62.76v8.35H334.25q-13.92,0-13.92-13.91V43.28q0-13.92,13.92-13.92h27.83v8.35H335.39q-6.7,0-6.71,6.68V56.05q0,6.72,6.74,6.71Z"/><path class="cls-2" d="M8.52,41.22l9,9L36.46,69.16l7.76-7.76L25.3,42.48l-9.42-9.42q-4.5-4.52-5.14-9.26c-.48-3.11,1-6.36,4.35-9.73Q19.22,9.95,23.64,10t9.26,4.83l4,4,7.2-7.21L38.52,6a20.93,20.93,0,0,0-7.91-4.9A17.1,17.1,0,0,0,20,.61Q14,2,8.05,8a28.56,28.56,0,0,0-6.33,9.5,18.2,18.2,0,0,0-.79,11.4Q2.34,35,8.52,41.22Z"/><path class="cls-2" d="M61.4,56.25,42.48,75.17l-9.42,9.42q-4.52,4.51-9.26,5.14t-9.73-4.35Q9.95,81.27,10,76.83t4.83-9.26l4-3.95-7.21-7.21L6,62a21,21,0,0,0-4.9,7.92A17.09,17.09,0,0,0,.61,80.48q1.43,6,7.36,12a28.87,28.87,0,0,0,9.5,6.33,18.2,18.2,0,0,0,11.4.79Q35,98.13,41.22,92l9-9L69.16,64Z"/><path class="cls-2" d="M92,59.26l-9-9L64,31.31l-7.76,7.76L75.17,58l9.42,9.42q4.51,4.52,5.14,9.26t-4.35,9.74c-2.74,2.74-5.59,4.12-8.55,4.11s-6-1.55-9.26-4.83l-3.95-4-7.21,7.2L62,94.48a21.11,21.11,0,0,0,7.92,4.91,17.06,17.06,0,0,0,10.6.47q6-1.42,12-7.36A28.87,28.87,0,0,0,98.76,83a18.16,18.16,0,0,0,.79-11.39Q98.13,65.43,92,59.26Z"/><path class="cls-2" d="M39.07,44.22,58,25.3l9.42-9.42q4.52-4.5,9.26-5.14c3.12-.48,6.36,1,9.74,4.35,2.74,2.75,4.11,5.59,4.11,8.55s-1.55,6-4.83,9.26l-4,4,7.2,7.2,5.54-5.54a21.07,21.07,0,0,0,4.91-7.91A17.17,17.17,0,0,0,99.86,20Q98.44,14,92.5,8.05A28.56,28.56,0,0,0,83,1.72,18.19,18.19,0,0,0,71.6.93Q65.44,2.34,59.26,8.52l-9,9L31.31,36.46Z"/></g></g></svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

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