Implementation, thanks amp

This commit is contained in:
2026-03-16 23:03:27 +13:00
parent b7539ac86e
commit ab508d827d
45 changed files with 2705 additions and 26 deletions

146
server/index.ts Normal file
View File

@@ -0,0 +1,146 @@
import { readFile, writeFile, mkdir } from 'fs/promises';
import { existsSync } from 'fs';
import { join } from 'path';
const DATA_DIR = join(import.meta.dir, 'data');
const PORT = parseInt(process.env.PORT || '3001');
// Ensure data dir exists
if (!existsSync(DATA_DIR)) await mkdir(DATA_DIR, { recursive: true });
// In-memory binding: pollId → ownerId (first writer wins)
const ownerBindings = new Map<string, string>();
// Load existing bindings from disk
async function loadBindings() {
const bindingsFile = join(DATA_DIR, '_bindings.json');
if (existsSync(bindingsFile)) {
const data = JSON.parse(await readFile(bindingsFile, 'utf-8'));
for (const [k, v] of Object.entries(data)) ownerBindings.set(k, v as string);
}
}
async function saveBindings() {
const bindingsFile = join(DATA_DIR, '_bindings.json');
await writeFile(bindingsFile, JSON.stringify(Object.fromEntries(ownerBindings)));
}
await loadBindings();
// Rate limiting: simple per-IP counter
const rateLimits = new Map<string, { count: number; reset: number }>();
const RATE_LIMIT = 60; // requests per minute
function checkRateLimit(ip: string): boolean {
const now = Date.now();
const entry = rateLimits.get(ip);
if (!entry || now > entry.reset) {
rateLimits.set(ip, { count: 1, reset: now + 60_000 });
return true;
}
entry.count++;
return entry.count <= RATE_LIMIT;
}
const server = Bun.serve({
port: PORT,
async fetch(req) {
const url = new URL(req.url);
const path = url.pathname;
// CORS
if (req.method === 'OPTIONS') {
return new Response(null, { headers: corsHeaders() });
}
// GET /api/polls/:id/snapshot
const getMatch = path.match(/^\/api\/polls\/([^/]+)\/snapshot$/);
if (getMatch && req.method === 'GET') {
const pollId = getMatch[1];
const file = join(DATA_DIR, `${pollId}.json`);
if (!existsSync(file)) {
return json({ error: 'Not found' }, 404);
}
const data = await readFile(file, 'utf-8');
return json(JSON.parse(data), 200);
}
// PUT /api/polls/:id/snapshot
const putMatch = path.match(/^\/api\/polls\/([^/]+)\/snapshot$/);
if (putMatch && req.method === 'PUT') {
const ip = req.headers.get('x-forwarded-for') || 'unknown';
if (!checkRateLimit(ip)) {
return json({ error: 'Rate limit exceeded' }, 429);
}
const pollId = putMatch[1];
const body = await req.json();
// Validate required fields
if (!body.ownerId || !body.title || !body.signature) {
return json({ error: 'Missing required fields' }, 400);
}
// Check owner binding
const existingOwner = ownerBindings.get(pollId);
if (existingOwner && existingOwner !== body.ownerId) {
return json({ error: 'Unauthorized: owner mismatch' }, 403);
}
// TODO: verify Ed25519 signature against body.ownerId
// For now, trust the ownerId binding as basic auth
// Bind on first write
if (!existingOwner) {
ownerBindings.set(pollId, body.ownerId);
await saveBindings();
}
const snapshot = {
pollId,
ownerId: body.ownerId,
ownerPeerId: body.ownerPeerId,
title: body.title,
description: body.description || '',
options: body.options || [],
voteCounts: body.voteCounts || {},
totalVotes: body.totalVotes || 0,
status: body.status || 'draft',
anonymous: body.anonymous ?? false,
updatedAt: Date.now()
};
const file = join(DATA_DIR, `${pollId}.json`);
await writeFile(file, JSON.stringify(snapshot, null, 2));
return json({ ok: true }, 200);
}
return json({ error: 'Not found' }, 404);
}
});
function corsHeaders(): Record<string, string> {
return {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, PUT, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type'
};
}
function json(data: unknown, status: number): Response {
return new Response(JSON.stringify(data), {
status,
headers: {
'Content-Type': 'application/json',
...corsHeaders()
}
});
}
console.log(`Snapshot server running on http://localhost:${PORT}`);

10
server/package.json Normal file
View File

@@ -0,0 +1,10 @@
{
"name": "evocracy-server",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "bun run --watch index.ts",
"start": "bun run index.ts"
}
}