Files
wikideck/server/index.ts
T
2026-10-19 12:16:49 -04:00

117 lines
6.5 KiB
TypeScript

import express from 'express';
import helmet from 'helmet';
import rateLimit from 'express-rate-limit';
import { randomBytes } from 'node:crypto';
import { mkdir, readFile, writeFile, rename } from 'node:fs/promises';
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { z } from 'zod';
import { mineSchema, transferSchema, validSignature, validTransfer, verifyBlock, verifyState } from './chain.ts';
import { wikipediaArticle } from './wiki.ts';
import { GENESIS, DIFFICULTY, POW, claimMessage, type ChainState, type Challenge, type Block } from '../shared/protocol.ts';
const root = fileURLToPath(new URL('../', import.meta.url));
const dataDir = process.env.WIKIDECK_DATA_DIR ?? resolve(root, 'data');
await mkdir(dataDir, { recursive: true });
const file = resolve(dataDir, 'chain.json');
let state: ChainState = { blocks: [], transfers: [] };
try {
state = z.object({ blocks: z.array(z.custom<Block>(b => typeof b === 'object' && b !== null)),
transfers: z.array(transferSchema) }).parse(JSON.parse(await readFile(file, 'utf8')));
if (!await verifyState(state)) throw new Error('Chain verification failed');
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
}
async function persist(next: ChainState) {
const temp = `${file}.tmp`;
await writeFile(temp, JSON.stringify(next, null, 2), { mode: 0o600 });
await rename(temp, file);
state = next;
}
async function nextChallenge(): Promise<Challenge> {
return { height: state.blocks.length + 1, previousHash: state.blocks.at(-1)?.hash ?? GENESIS,
seed: randomBytes(32).toString('hex'), article: await wikipediaArticle(),
difficulty: DIFFICULTY, createdAt: Date.now() };
}
let challenge = await nextChallenge();
let busy = false;
const app = express();
app.disable('x-powered-by');
app.use(helmet({ contentSecurityPolicy: { directives: {
defaultSrc: ["'self'"], scriptSrc: ["'self'", "'wasm-unsafe-eval'"],
imgSrc: ["'self'", 'data:', 'https://upload.wikimedia.org', 'https://thumb.wikimedia.org'],
styleSrc: ["'self'", "'unsafe-inline'"], connectSrc: ["'self'"], workerSrc: ["'self'", 'blob:'],
upgradeInsecureRequests: null,
} }, crossOriginEmbedderPolicy: false, strictTransportSecurity: process.env.NODE_ENV === 'production' }));
// Probes do not consume API rate limits or expose chain/wallet information.
app.get('/healthz', (_req, res) => {
res.setHeader('Cache-Control', 'no-store');
res.json({ status: 'ok' });
});
app.use('/api', rateLimit({ windowMs: 60_000, limit: 180, standardHeaders: 'draft-8', legacyHeaders: false }));
app.use(express.json({ limit: '8kb' }));
app.use('/api', (_req, res, next) => { res.setHeader('Cache-Control', 'no-store'); next(); });
app.use('/api', (req, res, next) => {
if (req.method === 'POST') {
const origin = req.get('origin');
const allowed = new Set(['http://127.0.0.1:5173', 'http://localhost:5173', 'http://127.0.0.1:3001', 'http://localhost:3001', process.env.PUBLIC_ORIGIN]);
if (origin && !allowed.has(origin)) { res.status(403).json({ error: 'Origin not allowed' }); return; }
if (!req.is('application/json')) { res.status(415).json({ error: 'JSON required' }); return; }
}
next();
});
app.get('/api/state', (_req, res) => res.json({ ...state, challenge, pow: POW, network: 'Local prototype' }));
app.get('/api/chain', (_req, res) => res.json(state));
app.post('/api/mine', rateLimit({ windowMs: 60_000, limit: 20, standardHeaders: 'draft-8', legacyHeaders: false }), async (req, res) => {
const result = mineSchema.safeParse(req.body);
if (!result.success) { res.status(400).json({ error: 'Invalid mining submission' }); return; }
const proof = result.data;
if (busy) { res.status(409).json({ error: 'Chain is updating. Refresh your challenge.' }); return; }
if (proof.seed !== challenge.seed) { res.status(409).json({ error: 'Someone mined this block first. A new article is ready.' }); return; }
if (!validSignature(proof.publicKey, proof.owner, claimMessage(challenge, proof.owner, proof.nonce, proof.hash), proof.signature)) {
res.status(403).json({ error: 'Invalid wallet signature' }); return;
}
busy = true; // Bound expensive verification to one 16 MiB Argon2 job at a time.
try {
const block: Block = { ...challenge, ...proof };
if (!await verifyBlock(block, challenge.previousHash, challenge.height)) {
res.status(400).json({ error: 'Invalid proof of work' }); return;
}
await persist({ ...state, blocks: [...state.blocks, block] });
challenge = await nextChallenge();
res.status(201).json({ block });
} finally { busy = false; }
});
app.post('/api/transfer', rateLimit({ windowMs: 60_000, limit: 15, standardHeaders: 'draft-8', legacyHeaders: false }), async (req, res) => {
const result = transferSchema.safeParse(req.body);
if (!result.success) { res.status(400).json({ error: 'Invalid transfer request' }); return; }
if (busy) { res.status(409).json({ error: 'Chain is updating. Try again.' }); return; }
if (!validTransfer(state, result.data)) { res.status(403).json({ error: 'Invalid signature, ownership, recipient, or transfer sequence' }); return; }
busy = true;
try {
await persist({ ...state, transfers: [...state.transfers, result.data] });
res.status(201).json({ ok: true });
} finally { busy = false; }
});
app.use('/api', (_req, res) => { res.status(404).json({ error: 'Unknown API route' }); });
app.use(express.static(resolve(root, 'dist')));
app.get('/{*path}', (_req, res) => res.sendFile(resolve(root, 'dist/index.html')));
app.use((err: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
console.error('Request failed:', err.name);
res.status(err instanceof SyntaxError ? 400 : 500).json({ error: err instanceof SyntaxError ? 'Malformed JSON' : 'Request failed; try again.' });
});
const port = Number(process.env.PORT ?? 3001);
const host = process.env.HOST ?? '127.0.0.1';
const server = app.listen(port, host, () => {
const bound = server.address();
console.log(`Wikideck API: http://${host}:${typeof bound === 'object' && bound ? bound.port : port} · ${state.blocks.length} verified blocks`);
});
// Drain in-flight proof validation and atomic writes before stopping the container.
for (const signal of ['SIGTERM', 'SIGINT'] as const) {
process.once(signal, () => {
const deadline = setTimeout(() => process.exit(1), 25_000);
deadline.unref();
server.close(() => { clearTimeout(deadline); process.exit(0); });
});
}