Files
wikideck/server/testnet/http.ts
T
2026-10-19 16:47:27 -04:00

119 lines
7.3 KiB
TypeScript

import express from 'express';
import helmet from 'helmet';
import rateLimit from 'express-rate-limit';
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { mkdir, open, unlink } from 'node:fs/promises';
import { z } from 'zod';
import { NETWORK } from '../../shared/testnet.ts';
import { CHAIN_ID, blockSchema, transferSchema } from './consensus.ts';
import { RandomX } from './randomx.ts';
import { Ledger } from './ledger.ts';
import { PeerNetwork } from './peers.ts';
import { wikipediaEnrichment } from '../enrichment.ts';
import { STARTER_ARTICLES } from '../../shared/catalog.ts';
const root = fileURLToPath(new URL('../../', import.meta.url));
const dataDir = process.env.WIKIDECK_DATA_DIR ?? resolve(root,'data');
await mkdir(dataDir,{recursive:true});
// Never permit two processes to rewrite the same ledger. A stale lock requires operator review.
const lockPath = resolve(dataDir,'testnet-node.lock');
const lock = await open(lockPath,'wx',0o600).catch(()=>{throw new Error('Data directory locked. Stop its other node; remove stale testnet-node.lock only after verifying no node is running.');});
await lock.writeFile(String(process.pid));
let engine: RandomX;
try { engine = new RandomX(); }
catch(e) { await lock.close();await unlink(lockPath);throw e; }
let ledger: Ledger;
try { ledger = await Ledger.open(dataDir,engine); }
catch(e) {engine.close();await lock.close();await unlink(lockPath);throw e;}
let peers: PeerNetwork;
try { peers = new PeerNetwork({ ledger, urls:(process.env.WIKIDECK_PEERS ?? '').split(',').map(s=>s.trim()).filter(Boolean),
allowPrivate:process.env.WIKIDECK_ALLOW_PRIVATE_PEERS === '1' }); }
catch(e) {engine.close();await lock.close();await unlink(lockPath);throw e;}
const app = express();
app.disable('x-powered-by');
app.use(helmet({contentSecurityPolicy:{directives:{defaultSrc:["'self'"],scriptSrc:["'self'"],
imgSrc:["'self'",'data:','https://upload.wikimedia.org','https://thumb.wikimedia.org'],styleSrc:["'self'","'unsafe-inline'"],
connectSrc:["'self'"],upgradeInsecureRequests:null}},crossOriginEmbedderPolicy:false,
strictTransportSecurity:process.env.NODE_ENV==='production'}));
app.get('/healthz',(_req,res)=>{res.setHeader('Cache-Control','no-store');res.json({status:'ok',network:NETWORK});});
app.use(express.json({limit:'68kb',strict:true}));
app.use(['/api','/p2p'],rateLimit({windowMs:60000,limit:300,standardHeaders:'draft-8',legacyHeaders:false}));
app.use(['/api','/p2p'],(req,res,next)=>{
res.setHeader('Cache-Control','no-store');
if(req.method==='POST') {
if(!req.is('application/json')){res.status(415).json({error:'JSON required'});return;}
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;}
}
next();
});
app.get('/api/state',(_req,res)=>res.json(ledger.snapshot()));
app.get('/api/chain',(_req,res)=>res.json(ledger.snapshot()));
app.get('/api/peers',(_req,res)=>res.json({peers:peers.status()}));
// Optional live data is intentionally not used by genesis, block validation or NFT ownership.
app.get('/api/wikipedia/:pageId', rateLimit({windowMs:60000,limit:60,standardHeaders:'draft-8',legacyHeaders:false}), async(req,res)=>{
if(typeof req.params.pageId!=='string'||!/^[1-9]\d{0,9}$/.test(req.params.pageId)){res.status(400).json({error:'Invalid Wikipedia page ID'});return;}
const pageId=Number(req.params.pageId);
if(!STARTER_ARTICLES.some(a=>a.pageId===pageId)){
res.status(404).json({error:'Page is not in the current consensus catalog'});return;
}
try {res.json(await wikipediaEnrichment.get(pageId));}
catch {res.status(503).json({error:'Wikipedia enrichment is temporarily unavailable. NFT data is unaffected.'});}
});
app.get('/p2p/status',(_req,res)=>res.json({network:NETWORK,chainId:CHAIN_ID,size:ledger.size,tip:ledger.snapshot().tip}));
app.get('/p2p/blocks',(req,res)=>{
const offset=Number(req.query.offset??0);
if(!Number.isSafeInteger(offset)||offset<0||offset>10000){res.status(400).json({error:'Invalid offset'});return;}
const blocks=ledger.inventory(offset,32);
res.json({blocks,next:offset+blocks.length<ledger.size?offset+blocks.length:null});
});
app.get('/p2p/mempool',(_req,res)=>res.json({transactions:ledger.snapshot().pendingTransfers}));
app.get('/api/nfts/:id', (req,res)=>{
const s=ledger.snapshot();const block=s.blocks.find(b=>b.tokenId===req.params.id);
if(!block){res.status(404).json({error:'NFT not found on the canonical chain'});return;}
let owner=block.owner,sequence=0;
for(const tx of s.transfers)if(tx.cardHash===block.tokenId){owner=tx.to;sequence++;}
res.json({network:NETWORK,chainId:CHAIN_ID,tokenId:block.tokenId,owner,sequence,blockHash:block.hash,
confirmations:s.blocks.length-block.height+1,article:block.article,stateRoot:s.blocks.at(-1)?.stateRoot});
});
app.post('/api/mining/template', (req,res)=>{
const body=z.object({owner:z.string().regex(/^[a-f0-9]{64}$/),publicKey:z.string().max(256)}).strict().parse(req.body);
res.json(ledger.template(body.owner,body.publicKey));
});
const workLimit=rateLimit({windowMs:60000,limit:30,standardHeaders:'draft-8',legacyHeaders:false});
app.post(['/api/blocks','/p2p/block'],workLimit,async(req,res)=>{
const block=blockSchema.parse(req.body);
const result=await ledger.submit(block);
res.status(result.accepted?201:200).json(result);
if(result.accepted)void peers.broadcastBlock(block).catch(()=>{});
});
app.post(['/api/transfer','/p2p/transaction'],workLimit,async(req,res)=>{
const tx=transferSchema.parse(req.body);
const id=await ledger.submitTransfer(tx);
res.status(202).json({id,status:'pending',message:'Ownership changes only when this transaction is mined.'});
void peers.broadcastTransfer(tx).catch(()=>{});
});
app.use(['/api','/p2p'],(_req,res)=>{res.status(404).json({error:'Unknown testnet endpoint'});});
app.use(express.static(resolve(root,'dist')));
app.get('/{*path}',(_req,res)=>res.sendFile(resolve(root,'dist/index.html')));
app.use((err:Error & {status?:number},_req:express.Request,res:express.Response,_next:express.NextFunction)=>{
const input=err instanceof z.ZodError||err instanceof SyntaxError;
const message=input?'Invalid request encoding':err.message;
// No keys/passwords are accepted by any node endpoint.
res.status(err.status===413?413:400).json({error:message.slice(0,200)});
});
const port=Number(process.env.PORT??3001),host=process.env.HOST??'127.0.0.1';
const server=app.listen(port,host,()=>{
const a=server.address();console.log(`Wikideck RandomX testnet: http://${host}:${typeof a==='object'&&a?a.port:port} · chain ${CHAIN_ID} · ${ledger.snapshot().blocks.length} blocks`);
peers.start();
});
let stopping=false;
for(const signal of ['SIGTERM','SIGINT']as const)process.once(signal,()=>{
if(stopping)return;stopping=true;peers.close();
const timeout=setTimeout(()=>process.exit(1),25000);timeout.unref();
server.close(async()=>{engine.close();await lock.close();await unlink(lockPath).catch(()=>{});clearTimeout(timeout);process.exit(0);});
});
server.on('error',async()=>{peers.close();engine.close();await lock.close();await unlink(lockPath).catch(()=>{});process.exit(1);});