Phase 10: accounts + cloud sync + Pathbuilder import

- Lean file-backed accounts (server/src/accounts.ts): scrypt-hashed passwords,
  hashed bearer tokens, per-user backup blob; persisted under DATA_DIR. /api routes
  (register/login/logout, PUT/GET /save) with per-IP throttle + 48MB body limit.
- Client cloud lib + Settings "Cloud sync": sign up / sign in, back up this device,
  restore from cloud (whole-backup push/pull, last-write-wins). Local-first default;
  the assistant key (localStorage) is never part of the synced Dexie backup.
- Pathbuilder 2e import: the existing character import now detects a Pathbuilder
  build JSON and converts it to a PF2e character (abilities/HP/saves/skills).
- Dockerfile creates a node-owned /data; compose mounts a named ttrpg-data volume.
- (Spectator links = existing /play?room=CODE; PDF export = existing Print.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-08 16:04:28 +02:00
parent f01e8ffe32
commit 76efc459bb
10 changed files with 448 additions and 5 deletions
+53 -2
View File
@@ -2,23 +2,74 @@ import path from 'node:path';
import Fastify from 'fastify';
import fastifyWebsocket from '@fastify/websocket';
import fastifyStatic from '@fastify/static';
import type { FastifyRequest } from 'fastify';
import { clientMessageSchema, type ServerMessage } from '@/lib/sync/messages';
import { RoomHub, type Sender } from './rooms';
import { AccountStore } from './accounts';
const PORT = Number(process.env.PORT ?? 8787);
const STATIC_DIR = path.resolve(process.env.STATIC_DIR ?? path.join(process.cwd(), 'dist'));
const MAX_PAYLOAD = 12 * 1024 * 1024; // 12 MB (map images)
const DATA_DIR = path.resolve(process.env.DATA_DIR ?? path.join(process.cwd(), 'data'));
const MAX_PAYLOAD = 12 * 1024 * 1024; // 12 MB (map images over WS)
const BODY_LIMIT = 48 * 1024 * 1024; // 48 MB (cloud backup blobs)
const ALLOWED_ORIGINS = (process.env.ALLOWED_ORIGINS ?? '').split(',').map((s) => s.trim()).filter(Boolean);
const bearer = (req: FastifyRequest): string | undefined => {
const h = req.headers.authorization;
return h?.startsWith('Bearer ') ? h.slice(7) : undefined;
};
// per-socket token bucket: 80 messages / 10s
const RATE = { capacity: 80, refillMs: 10_000 };
export function buildServer() {
const app = Fastify({ bodyLimit: MAX_PAYLOAD });
const app = Fastify({ bodyLimit: BODY_LIMIT });
const hub = new RoomHub();
const accounts = new AccountStore(DATA_DIR);
void accounts.load();
const sweeper = setInterval(() => hub.sweep(), 5 * 60 * 1000);
app.addHook('onClose', async () => clearInterval(sweeper));
// Simple per-IP throttle for the account/cloud API.
const ipHits = new Map<string, { n: number; reset: number }>();
app.addHook('onRequest', async (req, reply) => {
if (!req.url.startsWith('/api/')) return;
const now = Date.now();
const e = ipHits.get(req.ip);
if (!e || now > e.reset) ipHits.set(req.ip, { n: 1, reset: now + 60_000 });
else if (++e.n > 60) await reply.code(429).send({ error: 'rate' });
});
// ---- accounts + cloud backup sync ----
app.post('/api/register', async (req, reply) => {
const { username, password } = (req.body ?? {}) as { username?: string; password?: string };
const r = await accounts.register(String(username ?? ''), String(password ?? ''));
if (!r.ok) return reply.code(400).send({ error: r.code, message: r.message });
return { token: r.token, username: r.username };
});
app.post('/api/login', async (req, reply) => {
const { username, password } = (req.body ?? {}) as { username?: string; password?: string };
const r = await accounts.login(String(username ?? ''), String(password ?? ''));
if (!r.ok) return reply.code(401).send({ error: r.code, message: r.message });
return { token: r.token, username: r.username };
});
app.post('/api/logout', async (req) => { const t = bearer(req); if (t) await accounts.logout(t); return { ok: true }; });
app.put('/api/save', async (req, reply) => {
const u = await accounts.userByToken(bearer(req));
if (!u) return reply.code(401).send({ error: 'unauthorized' });
const blob = (req.body as { blob?: string } | undefined)?.blob;
if (typeof blob !== 'string') return reply.code(400).send({ error: 'bad-blob' });
await accounts.saveBlob(u.id, blob);
return { ok: true, size: blob.length };
});
app.get('/api/save', async (req, reply) => {
const u = await accounts.userByToken(bearer(req));
if (!u) return reply.code(401).send({ error: 'unauthorized' });
const blob = await accounts.loadBlob(u.id);
if (blob === null) return reply.code(404).send({ error: 'no-save' });
return reply.header('content-type', 'application/json').send(blob);
});
void app.register(fastifyWebsocket, { options: { maxPayload: MAX_PAYLOAD } });
void app.register(async (instance) => {