This commit is contained in:
2026-10-19 12:16:49 -04:00
parent d7ecab6494
commit 6d3f470922
10446 changed files with 1626249 additions and 2216 deletions
+58
View File
@@ -0,0 +1,58 @@
import { createHash, createPublicKey, verify } from 'node:crypto';
import { argon2id } from 'hash-wasm';
import { z } from 'zod';
import { claimMessage, transferMessage, proofMessage, POW, GENESIS, DIFFICULTY, ownerOf,
type Block, type Challenge, type ChainState, type Transfer } from '../shared/protocol.ts';
const hex = z.string().regex(/^[a-f0-9]{64}$/);
const publicKey = z.string().regex(/^[A-Za-z0-9+/]+={0,2}$/).max(256);
const signature = z.string().regex(/^[A-Za-z0-9+/]+={0,2}$/).max(128);
export const mineSchema = z.object({ owner: hex, publicKey, signature,
nonce: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER), hash: hex,
seed: hex }).strict();
export const transferSchema = z.object({ cardHash: hex, from: hex, to: hex,
sequence: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER), publicKey, signature }).strict();
export function address(key: string): string {
return createHash('sha256').update(Buffer.from(key, 'base64')).digest('hex');
}
export function validSignature(key: string, owner: string, message: string, sig: string): boolean {
try {
if (address(key) !== owner) return false;
const imported = createPublicKey({ key: Buffer.from(key, 'base64'), format: 'der', type: 'spki' });
if (imported.asymmetricKeyType !== 'ec' || imported.asymmetricKeyDetails?.namedCurve !== 'prime256v1') return false;
return verify('sha256', Buffer.from(message), { key: imported, dsaEncoding: 'ieee-p1363' }, Buffer.from(sig, 'base64'));
} catch { return false; }
}
export async function powHash(c: Challenge, owner: string, nonce: number): Promise<string> {
return argon2id({ ...POW, password: proofMessage(c, owner, nonce),
salt: Buffer.from(c.seed, 'hex'), outputType: 'hex' });
}
export async function verifyBlock(block: Block, previousHash: string, height: number): Promise<boolean> {
if (block.previousHash !== previousHash || block.height !== height || block.difficulty !== DIFFICULTY) return false;
if (!/^[a-f0-9]{64}$/.test(block.seed) || !/^[a-f0-9]{64}$/.test(block.hash)) return false;
if (!Number.isSafeInteger(block.nonce) || block.nonce < 0 || !Number.isSafeInteger(block.createdAt)) return false;
if (!Number.isSafeInteger(block.article.pageId) || block.article.pageId <= 0) return false;
if (!block.hash.startsWith('0'.repeat(DIFFICULTY))) return false;
if (!validSignature(block.publicKey, block.owner, claimMessage(block, block.owner, block.nonce, block.hash), block.signature)) return false;
return await powHash(block, block.owner, block.nonce) === block.hash;
}
export function validTransfer(state: ChainState, t: Transfer): boolean {
return transferSchema.safeParse(t).success && t.from !== t.to &&
ownerOf(state, t.cardHash) === t.from &&
t.sequence === state.transfers.filter(x => x.cardHash === t.cardHash).length &&
validSignature(t.publicKey, t.from, transferMessage(t), t.signature);
}
export async function verifyState(state: ChainState): Promise<boolean> {
let previous = GENESIS;
for (const [index, block] of state.blocks.entries()) {
if (!await verifyBlock(block, previous, index + 1)) return false;
previous = block.hash;
}
const checked: ChainState = { blocks: state.blocks, transfers: [] };
for (const t of state.transfers) {
if (!validTransfer(checked, t)) return false;
checked.transfers.push(t);
}
return true;
}
+116
View File
@@ -0,0 +1,116 @@
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); });
});
}
+44
View File
@@ -0,0 +1,44 @@
import { z } from 'zod';
import type { Article } from '../shared/protocol.ts';
import { STARTER_ARTICLES } from '../shared/catalog.ts';
const pageSchema = z.object({
pageid: z.number().int().positive(), title: z.string(), extract: z.string().default(''),
lastrevid: z.number().int().nonnegative().default(0),
thumbnail: z.object({ source: z.string().url() }).optional(),
categories: z.array(z.object({ title: z.string() })).optional(),
});
function category(title: string, categories: string[]): string {
const text = `${title} ${categories.join(' ')}`.toLowerCase();
if (/astronom|planet|space|galax|moon|star\b/.test(text)) return 'Space';
if (/animal|plant|species|ocean|biolog|mollusc/.test(text)) return 'Nature';
if (/births|deaths|people|scientist/.test(text)) return 'People';
if (/physic|chem|math|science|technolog/.test(text)) return 'Science';
if (/histor|ancient|archaeolog|empire/.test(text)) return 'History';
return 'Culture';
}
export async function wikipediaArticle(): Promise<Article> {
if (process.env.WIKIDECK_OFFLINE === '1') return { ...STARTER_ARTICLES[Math.floor(Math.random() * STARTER_ARTICLES.length)] };
try {
const url = new URL('https://en.wikipedia.org/w/api.php');
url.search = new URLSearchParams({ action: 'query', format: 'json', formatversion: '2',
generator: 'random', grnnamespace: '0', grnlimit: '6', prop: 'extracts|pageimages|info|categories',
exintro: '1', explaintext: '1', exchars: '700', piprop: 'thumbnail', pithumbsize: '900', cllimit: '8' }).toString();
const response = await fetch(url, { signal: AbortSignal.timeout(6000), redirect: 'error',
headers: { 'User-Agent': 'Wikideck/0.1 (local educational card-game prototype)', Accept: 'application/json' } });
if (!response.ok) throw new Error('Wikipedia unavailable');
const raw = await response.text();
if (raw.length > 1_000_000) throw new Error('Wikipedia response too large');
const result = z.object({ query: z.object({ pages: z.array(pageSchema) }) }).parse(JSON.parse(raw));
const page = result.query.pages.find(p => p.extract.length > 120 && p.thumbnail) ?? result.query.pages.find(p => p.extract.length > 120);
if (!page) throw new Error('No suitable article');
const image = page.thumbnail?.source;
return { pageId: page.pageid, title: page.title, extract: page.extract.slice(0, 700),
image: image && ['https://upload.wikimedia.org', 'https://thumb.wikimedia.org'].includes(new URL(image).origin) ? image : undefined,
url: `https://en.wikipedia.org/?curid=${page.pageid}`, revisionId: page.lastrevid,
source: 'live', category: category(page.title, page.categories?.map(c => c.title) ?? []) };
} catch {
return { ...STARTER_ARTICLES[Math.floor(Math.random() * STARTER_ARTICLES.length)] };
}
}