Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 18 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,28 @@ The public dashboard is served by [sysnode-info](https://github.com/syscoin/sysn

## Runtime surface

Public, unauthenticated routes (cached, read-only):

- `/mnstats`, `/masternodes`, `/mnlist`, `/mnsearch` — masternode data
- `/governance` — active and historical governance proposals
- `/csvparser` — CSV-ingest helper used by the dashboard
Public, unauthenticated routes (read-only). Canonical URL casing is
**lowercase**, matching the historical `https://syscoin.dev/mnstats`
convention and the existing `/govlist`. Express's default routing is
case-insensitive, so legacy camelCase callers (`/mnStats`, `/mnList`,
…) continue to work at the route layer; the bundled nginx config
(`deploy/nginx/sysnode.conf.example`) uses a case-insensitive regex
match for the same reason, so external bookmarks/clients with mixed
casing keep working through the same-origin proxy. New integrations
should use lowercase.

- `GET /mnstats` — chain + market + masternode summary, refreshed by `services/sysMain.js`
- `GET /mncount` — historical masternode count series
- `GET /mnlist` — fresh `masternode_list` RPC passthrough
- `POST /mnsearch` — paginated/searched view over the in-memory tracker snapshot
- `POST /govlist` — active + historical governance proposals (Syscoin Core `gobject list`)

Authenticated routes (cookie + CSRF, same-site):

- `/auth/*` — registration, verification, login, session, delete account
- `/vault/*` — encrypted per-user blobs (notification prefs, proposal drafts)
- `/gov/proposals/*` — governance proposal wizard, submissions, collateral PSBT, vote receipts
- `/vault` — encrypted per-user blob (notification prefs, proposal drafts; one row per user, conditional GET/PUT with ETag)
- `/gov/*` — masternode lookup, vote relay, vote receipts (`/gov/mns/lookup`, `/gov/vote`, `/gov/receipts`, …)
- `/gov/proposals/*` — governance proposal wizard, submissions, collateral PSBT

## Requirements

Expand Down
70 changes: 48 additions & 22 deletions deploy/nginx/sysnode.conf.example
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,30 @@
#
# Layout matches the same-origin model documented in the project README:
#
# https://sysnode.example.com/ -> sysnode-info build (port 3000)
# https://sysnode.example.com/auth/* -> sysnode-backend (port 3001)
# https://sysnode.example.com/vault/* -> sysnode-backend (port 3001)
# https://sysnode.example.com/gov/* -> sysnode-backend (port 3001)
# https://sysnode.example.com/ -> sysnode-info build (port 3000)
# https://sysnode.example.com/auth/* -> sysnode-backend (port 3001)
# https://sysnode.example.com/vault -> sysnode-backend (port 3001)
# https://sysnode.example.com/gov/* -> sysnode-backend (port 3001)
# https://sysnode.example.com/mnstats -> sysnode-backend (port 3001)
# https://sysnode.example.com/mncount -> sysnode-backend (port 3001)
# https://sysnode.example.com/mnlist -> sysnode-backend (port 3001)
# https://sysnode.example.com/mnsearch -> sysnode-backend (port 3001)
# https://sysnode.example.com/govlist -> sysnode-backend (port 3001)
#
# Public anonymous routes that the dashboard polls (mnstats, mnCount,
# masternodes, governance, etc.) are also served by sysnode-backend.
# They're listed individually so the SPA's catch-all `location /` can
# fall through to the React static build for everything else.
# Public anonymous routes (/mnstats, /mncount, /mnlist, /mnsearch,
# /govlist) are matched with a case-insensitive regex so the SPA's
# catch-all `location /` falls through to the React static build for
# everything else, while legacy camelCase callers (browser bookmarks,
# external integrations that predate the lowercase canonical such as
# the historical `https://syscoin.dev/mnstats` URL still referenced
# by older syshub builds) keep working through the same-origin proxy.
#
# Heads up — paths like `/governance`, `/masternodes`, `/login`, `/vault`-
# beyond-the-bare-segment are *client-side React routes* served by the
# SPA. Don't add nginx `location /governance` / `location /masternodes`
# blocks: the backend doesn't expose those paths (its public routes
# are `/govlist`, `/mnsearch`, `/mnlist`, `/mnstats`, `/mncount`), and
# proxying them to :3001 would 404 the SPA pages users navigate to.
#
# Do NOT add `add_header Strict-Transport-Security ...` here. Both apps
# emit HSTS in code (helmet on the backend, the security-header map in
Expand All @@ -39,21 +54,32 @@ server {
client_max_body_size 5M;

# ---- backend (sysnode-backend on :3001) -------------------------------
# The backend mounts auth/vault/gov at the path root, so we proxy
# without rewriting. Trailing-slash matching means /auth, /authNNN,
# etc. all match — adjust if your backend ever serves something at
# /authentication that should NOT be proxied.
location /auth/ { proxy_pass http://127.0.0.1:3001; include /etc/nginx/snippets/sysnode-proxy.conf; }
location /vault/ { proxy_pass http://127.0.0.1:3001; include /etc/nginx/snippets/sysnode-proxy.conf; }
location /gov/ { proxy_pass http://127.0.0.1:3001; include /etc/nginx/snippets/sysnode-proxy.conf; }
# The backend mounts /auth, /vault, /gov at the path root, so we proxy
# without rewriting. Trailing-slash matching (`location /auth/`) means
# only paths *under* /auth/ are proxied (e.g. /auth/me, /auth/login);
# something else at /authentication would NOT match and would fall
# through to the SPA. /vault is matched as an exact location because
# the SPA hits exactly `/vault` (GET / PUT of the encrypted blob);
# listing it as a prefix would also catch client-side routes like
# `/vault/import` that need to fall through to the SPA.
location /auth/ { proxy_pass http://127.0.0.1:3001; include /etc/nginx/snippets/sysnode-proxy.conf; }
location = /vault { proxy_pass http://127.0.0.1:3001; include /etc/nginx/snippets/sysnode-proxy.conf; }
location /gov/ { proxy_pass http://127.0.0.1:3001; include /etc/nginx/snippets/sysnode-proxy.conf; }

# Public anonymous data routes. Listed exactly so /<random> falls
# through to the SPA below. If you add a new public backend route,
# add it here too.
location = /mnstats { proxy_pass http://127.0.0.1:3001; include /etc/nginx/snippets/sysnode-proxy.conf; }
location = /mnCount { proxy_pass http://127.0.0.1:3001; include /etc/nginx/snippets/sysnode-proxy.conf; }
location /masternodes { proxy_pass http://127.0.0.1:3001; include /etc/nginx/snippets/sysnode-proxy.conf; }
location /governance { proxy_pass http://127.0.0.1:3001; include /etc/nginx/snippets/sysnode-proxy.conf; }
# Public anonymous data routes. Canonical casing is lowercase
# (matches the historical `https://syscoin.dev/mnstats` URL still
# used by older syshub builds and the existing `/govlist`).
# `~*` is a case-INsensitive regex match so legacy callers using
# camelCase URLs (`/mnStats`, `/mnList`, …) keep working through
# the same-origin proxy — this mirrors Express's own default
# case-insensitive route matching at the route layer. The anchored
# `^/...$` keeps `/mnstatsfoo` etc. from being proxied so they fall
# through to the SPA's catch-all `location /` below. If you add a
# new public backend route, extend this alternation.
location ~* ^/(mnstats|mncount|mnlist|mnsearch|govlist)$ {
proxy_pass http://127.0.0.1:3001;
include /etc/nginx/snippets/sysnode-proxy.conf;
}

# ---- frontend (sysnode-info on :3000) ---------------------------------
# Catch-all for the SPA. server.js falls back to index.html for any
Expand Down
2 changes: 1 addition & 1 deletion lib/reminderDispatcher.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -634,7 +634,7 @@ describe('createReminderDispatcher.tick', () => {
{ height: 600_000, epochSec: 0 },
{ height: 600_000, epochSec: null },
{ height: 600_000, epochSec: NaN },
// past epoch: /mnStats lagging behind the tip
// past epoch: /mnstats lagging behind the tip
{ height: 600_000, epochSec: Math.floor((NOW_MS - 10_000) / 1000) },
// epoch equal to now: boundary — also treated as stale
{ height: 600_000, epochSec: Math.floor(NOW_MS / 1000) },
Expand Down
42 changes: 0 additions & 42 deletions routes/masternodes.js

This file was deleted.

4 changes: 2 additions & 2 deletions routes/mnCount.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

const express = require('express');

// GET /mnCount
// GET /mncount
// ------------
// Historical daily total of masternodes on the network, used by the
// TrendChart component on sysnode-info's homepage.
Expand All @@ -28,7 +28,7 @@ function createMnCountRouter({ repo, log = () => {} } = {}) {
}
const router = express.Router();

router.get('/mnCount', (_req, res) => {
router.get('/mncount', (_req, res) => {
try {
const rows = repo.getAll();
res.json(rows);
Expand Down
8 changes: 4 additions & 4 deletions routes/mnCount.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ function mountApp(router) {
return app;
}

describe('GET /mnCount', () => {
describe('GET /mncount', () => {
let db;
let repo;

Expand All @@ -25,7 +25,7 @@ describe('GET /mnCount', () => {

test('empty table → 200 with []', async () => {
const app = mountApp(createMnCountRouter({ repo }));
const res = await request(app).get('/mnCount');
const res = await request(app).get('/mncount');
expect(res.status).toBe(200);
expect(res.body).toEqual([]);
});
Expand All @@ -36,7 +36,7 @@ describe('GET /mnCount', () => {
repo.upsertByDate('2024-03-16', 2201, Date.parse('2024-03-16T00:00:05Z'));

const app = mountApp(createMnCountRouter({ repo }));
const res = await request(app).get('/mnCount');
const res = await request(app).get('/mncount');
expect(res.status).toBe(200);
expect(res.body).toEqual([
{ date: '2024-03-14', users: 2199 },
Expand All @@ -58,7 +58,7 @@ describe('GET /mnCount', () => {
log: (level, event, meta) => logs.push({ level, event, meta }),
})
);
const res = await request(app).get('/mnCount');
const res = await request(app).get('/mncount');
expect(res.status).toBe(500);
expect(res.body).toEqual({ error: 'internal' });
expect(logs.some((l) => l.event === 'mncount_read_failed')).toBe(true);
Expand Down
14 changes: 12 additions & 2 deletions routes/mnList.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,23 @@
const express = require("express");
const router = express.Router();
const { client, rpcServices } = require("../services/rpcClient");
const securityLog = require("../lib/securityLog");

router.get("/mnList", async (req, res) => {
router.get("/mnlist", async (req, res) => {
try {
const masternodes = await rpcServices(client.callRpc).masternode_list().call();
res.status(200).json(masternodes);
} catch (err) {
res.status(500).json({ error: err.message });
// Mirror routes/governance.js (govlist.rpc_failed). An RPC failure
// can leak internal hostnames/ports/stack-adjacent detail (e.g.
// "ECONNREFUSED 127.0.0.1:8370") via err.message, so log full
// detail server-side and return an opaque 500 to the client to
// match the error-shape used by the rest of the API.
securityLog.event('mnList.rpc_failed', {
req,
message: err && err.message,
});
res.status(500).json({ error: 'internal' });
}
});

Expand Down
19 changes: 16 additions & 3 deletions routes/mnSearch.js
Original file line number Diff line number Diff line change
@@ -1,15 +1,28 @@
const express = require("express");
const moment = require("moment");
const router = express.Router();
const { masternodesArr } = require("../data/dataStore");

router.post("/mnSearch", (req, res) => {
// IMPORTANT: do NOT destructure `masternodesArr` at require time. The
// masternode tracker REASSIGNS `dataStore.masternodesArr = []` every
// 10 seconds (see services/masternodeTracker.js) and pushes into the
// fresh array, so a captured reference would forever see the original
// empty `[]` from data/dataStore.js. Read the property on every call
// to pick up whatever the tracker most recently published. The same
// reasoning is documented at server.js (`masternodesProvider`), which
// uses an arrow function for exactly this reason.
const dataStore = require("../data/dataStore");

router.post("/mnsearch", (req, res) => {
const { page = 1, sortBy = "", sortDesc = false } = req.body;
const perPage = req.body.perPage > 0 && req.body.perPage <= 90 ? req.body.perPage : 30;
const search = (req.body.search || "").replace(/ /g, "");

const query = search.includes(":") ? search.split(":")[0] : search;

const masternodesArr = Array.isArray(dataStore.masternodesArr)
? dataStore.masternodesArr
: [];

const filtered = masternodesArr
.filter(mn =>
mn.address.split(":")[0].includes(query) ||
Expand Down Expand Up @@ -39,4 +52,4 @@ router.post("/mnSearch", (req, res) => {
res.status(200).send({ returnArr: paginated, mnNumb: filtered.length });
});

module.exports = router;
module.exports = router;
2 changes: 1 addition & 1 deletion routes/mnStats.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ const router = express.Router();
const calculations = require("../services/calculations");
const data = require("../data/dataStore");

router.get("/mnStats", (req, res) => {
router.get("/mnstats", (req, res) => {
res.status(200).send({
stats: calculations(),
mapData: data.mapData,
Expand Down
6 changes: 2 additions & 4 deletions server.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ require('./services/masternodeTracker');
// Legacy public routes (no cookies, no credentials; stats + governance list
// + masternode list etc. consumed by sysnode-info and third parties).
const mnStatsRoute = require('./routes/mnStats');
const masternodesRoute = require('./routes/masternodes');
const governanceRoute = require('./routes/governance');
const { createMnCountRouter } = require('./routes/mnCount');
const mnListRoute = require('./routes/mnList');
Expand Down Expand Up @@ -132,10 +131,10 @@ app.use((req, res, next) => {
const dbPath = process.env.SYSNODE_DB_PATH || './data/sysnode.db';
const db = openDatabase(dbPath);

// Historical masternode-count store (feeds the /mnCount endpoint +
// Historical masternode-count store (feeds the /mncount endpoint +
// the homepage TrendChart). We construct the repo up front because
// three independent callers need it: the one-time seeder, the daily
// logger that appends new rows, and the /mnCount HTTP route.
// logger that appends new rows, and the /mncount HTTP route.
//
// seedMasternodeCount is idempotent: it loads the committed CSV
// (db/seeds/masternode-count.csv) only when the table is empty, so
Expand Down Expand Up @@ -362,7 +361,6 @@ mountAuthAndVault(app, {
// registration exactly as it was before this PR.
// -----------------------------------------------------------------------------
app.use(mnStatsRoute);
app.use(masternodesRoute);
app.use(governanceRoute);
app.use(
createMnCountRouter({
Expand Down
2 changes: 1 addition & 1 deletion services/mnCountLogger.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -435,7 +435,7 @@ describe('createMnCountLogger', () => {
// outside runAndReschedule()'s try/catch. A transient SQLite
// read failure there would reject the returned promise, the
// setTimeout callback didn't attach a .catch, and the scheduler
// silently died — daily /mnCount updates would halt until
// silently died — daily /mncount updates would halt until
// process restart. This test wires a repo whose getLatestDate()
// throws once on the first tick, then recovers, and asserts the
// logger stays alive and rearms.
Expand Down
Loading
Loading