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(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 { 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); }); }); }