Backend in @gashboard/api (Express 5 + TS, ~1.2k LoC):
Auth (NIP-98 over HTTP, lifted from indeehub pattern):
- Client signs a kind-27235 event with method+URL, base64s as
Authorization: Nostr <event>. Server verifies sig, freshness
(±120s), method/URL tags via constant-time string compare.
- npub allowlist decoded to hex once at boot, fail-closed if any
entry is malformed or list is empty.
- HS256 JWT sessions returning {token, npub, expiresAt}.
- express-rate-limit on POST /api/auth/login (10/min/IP).
Datum integration (the trickier half):
- HTTP Digest *SHA-256* client (community-fork Datum uses sha-256,
not md5; node has no first-class support — hand-rolled in
digest.ts: parse challenge → ha1=sha256(user:realm:pw),
ha2=sha256(method:URI), response=sha256(...) → retry).
- HTML parsers for /clients (per-worker) and /threads (auth-less
fallback) using node-html-parser.
- Profile matcher: UserAgent contains "NerdQAxe" → NerdQAxe;
else worker-name suffix on auth username → workerNameMatchers.
Live UA strings observed: NerdQAxe self-IDs; Bitaxe / Avalon
Nano 3 / Avalon Mini 3 all report cgminer/4.11.1, must match
via workername.
- 5s poll interval, 10s AbortController timeout per upstream call,
in-memory snapshot, /api/datum/stats + SSE /api/datum/stream.
Hardened-by-default Express setup:
helmet CSP (frame-ancestors 'none', script-src 'self'),
pino with redaction (auth header, *.password, *.token, *.jwt,
*.sig), AppError class + central errorHandler, zod env validation,
graceful shutdown on SIGTERM/SIGINT.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
72 lines
1.9 KiB
TypeScript
72 lines
1.9 KiB
TypeScript
import express from "express";
|
|
import helmet from "helmet";
|
|
import cors from "cors";
|
|
import { pinoHttp } from "pino-http";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import fs from "node:fs";
|
|
|
|
import { config } from "./config.js";
|
|
import { logger } from "./logger.js";
|
|
import { authRouter } from "./auth/routes.js";
|
|
import { datumRouter } from "./datum/routes.js";
|
|
import { errorHandler } from "./errors.js";
|
|
|
|
export function buildApp() {
|
|
const app = express();
|
|
app.disable("x-powered-by");
|
|
app.set("trust proxy", 1);
|
|
|
|
app.use(
|
|
helmet({
|
|
contentSecurityPolicy: {
|
|
useDefaults: true,
|
|
directives: {
|
|
"default-src": ["'self'"],
|
|
"script-src": ["'self'"],
|
|
"style-src": ["'self'", "'unsafe-inline'"],
|
|
"img-src": ["'self'", "data:"],
|
|
"connect-src": ["'self'"],
|
|
"font-src": ["'self'", "data:"],
|
|
"frame-ancestors": ["'none'"],
|
|
},
|
|
},
|
|
crossOriginEmbedderPolicy: false,
|
|
}),
|
|
);
|
|
|
|
if (config.corsOrigin) {
|
|
app.use(cors({ origin: config.corsOrigin, credentials: false }));
|
|
}
|
|
|
|
app.use(express.json({ limit: "32kb" }));
|
|
app.use(pinoHttp({ logger }));
|
|
|
|
app.get("/healthz", (_req, res) => {
|
|
res.json({ ok: true });
|
|
});
|
|
app.use("/api/auth", authRouter);
|
|
app.use("/api/datum", datumRouter);
|
|
|
|
const staticDir = config.staticDir
|
|
? config.staticDir
|
|
: resolveDefaultStaticDir();
|
|
|
|
if (staticDir && fs.existsSync(staticDir)) {
|
|
app.use(express.static(staticDir, { index: false, maxAge: "1h" }));
|
|
app.get(/.*/, (_req, res, next) => {
|
|
const indexFile = path.join(staticDir, "index.html");
|
|
if (!fs.existsSync(indexFile)) return next();
|
|
res.sendFile(indexFile);
|
|
});
|
|
}
|
|
|
|
app.use(errorHandler);
|
|
return app;
|
|
}
|
|
|
|
function resolveDefaultStaticDir(): string {
|
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
return path.resolve(here, "../../web/dist");
|
|
}
|