Files
gashboard/apps/api/src/config.ts
Dorian de353878f6 feat(api): nostr nip-98 login, jwt sessions, datum digest poller
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>
2026-05-06 15:58:35 +01:00

68 lines
2.0 KiB
TypeScript

import { nip19 } from "nostr-tools";
import { z } from "zod";
const RawEnv = z.object({
PORT: z.coerce.number().int().min(1).max(65535).default(8080),
NODE_ENV: z.enum(["development", "production", "test"]).default("production"),
LOG_LEVEL: z
.enum(["fatal", "error", "warn", "info", "debug", "trace", "silent"])
.default("info"),
CORS_ORIGIN: z.string().optional(),
STATIC_DIR: z.string().optional(),
DATUM_URL: z.string().url(),
DATUM_ADMIN_USER: z.string().default("admin"),
DATUM_ADMIN_PASSWORD: z.string().min(1),
DATUM_POLL_INTERVAL_MS: z.coerce.number().int().min(1000).default(5000),
NOSTR_ALLOWED_NPUBS: z.string().min(1),
JWT_SECRET: z
.string()
.min(32, "JWT_SECRET must be at least 32 chars (use openssl rand -hex 32)"),
JWT_TTL_SECONDS: z.coerce.number().int().min(60).default(86400),
});
function parseAllowedNpubs(input: string): string[] {
const out: string[] = [];
for (const raw of input.split(",").map((s) => s.trim()).filter(Boolean)) {
const decoded = nip19.decode(raw);
if (decoded.type !== "npub") {
throw new Error(`NOSTR_ALLOWED_NPUBS entry is not an npub: ${raw}`);
}
const hex = decoded.data;
if (!/^[0-9a-f]{64}$/.test(hex)) {
throw new Error(`Decoded npub is not 64-char hex: ${raw}`);
}
out.push(hex);
}
if (out.length === 0) {
throw new Error("NOSTR_ALLOWED_NPUBS produced an empty allowlist — refusing to start");
}
return out;
}
const parsed = RawEnv.parse(process.env);
export const config = {
port: parsed.PORT,
nodeEnv: parsed.NODE_ENV,
logLevel: parsed.LOG_LEVEL,
corsOrigin: parsed.CORS_ORIGIN,
staticDir: parsed.STATIC_DIR,
datum: {
url: parsed.DATUM_URL.replace(/\/$/, ""),
adminUser: parsed.DATUM_ADMIN_USER,
adminPassword: parsed.DATUM_ADMIN_PASSWORD,
pollIntervalMs: parsed.DATUM_POLL_INTERVAL_MS,
},
nostr: {
allowedHexPubkeys: parseAllowedNpubs(parsed.NOSTR_ALLOWED_NPUBS),
},
jwt: {
secret: parsed.JWT_SECRET,
ttlSeconds: parsed.JWT_TTL_SECONDS,
},
} as const;