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
+41
View File
@@ -0,0 +1,41 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { promises as fs } from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { AccountStore } from './accounts';
describe('AccountStore', () => {
let dir: string;
let store: AccountStore;
beforeEach(async () => { dir = await fs.mkdtemp(path.join(os.tmpdir(), 'acct-')); store = new AccountStore(dir); });
it('registers, logs in, and rejects duplicates / bad input / bad creds', async () => {
expect((await store.register('alice', 'password123')).ok).toBe(true);
expect((await store.register('alice', 'password123')).ok).toBe(false); // taken
expect((await store.register('ab', 'password123')).ok).toBe(false); // username too short
expect((await store.register('bob', 'short')).ok).toBe(false); // weak password
expect((await store.login('alice', 'password123')).ok).toBe(true);
expect((await store.login('alice', 'nope')).ok).toBe(false);
});
it('stores + serves a per-user blob behind a bearer token', async () => {
const r = await store.register('carol', 'password123');
if (!r.ok) throw new Error('register failed');
const u = await store.userByToken(r.token);
expect(u).toBeTruthy();
await store.saveBlob(u!.id, '{"x":1}');
expect(await store.loadBlob(u!.id)).toBe('{"x":1}');
expect(await store.userByToken('garbage-token')).toBeNull();
});
it('persists users across instances + logout invalidates the token', async () => {
const r = await store.register('dave', 'password123');
if (!r.ok) throw new Error('register failed');
await store.logout(r.token);
expect(await store.userByToken(r.token)).toBeNull();
const store2 = new AccountStore(dir);
await store2.load();
expect((await store2.login('dave', 'password123')).ok).toBe(true);
});
});
+124
View File
@@ -0,0 +1,124 @@
import crypto from 'node:crypto';
import { promises as fs } from 'node:fs';
import path from 'node:path';
/**
* Lean file-backed accounts + cloud-backup store. No external DB — a single
* users.json plus one blob file per user under DATA_DIR. Passwords are scrypt-
* hashed with a per-user salt; bearer tokens are stored hashed. Single-process
* only (writes are serialised through a promise queue). Good enough for a small
* self-hosted instance; the protocol leaves room to swap in a real DB later.
*/
interface User {
id: string;
username: string; // display
salt: string;
hash: string;
tokens: string[]; // sha256(token)
createdAt: number;
updatedAt: number;
}
const USERNAME_RE = /^[a-zA-Z0-9_.-]{3,32}$/;
const MAX_TOKENS = 10;
function sha256(s: string): string { return crypto.createHash('sha256').update(s).digest('hex'); }
function hashPassword(password: string, salt: string): string { return crypto.scryptSync(password, salt, 64).toString('hex'); }
function timingEqual(a: string, b: string): boolean {
const ab = Buffer.from(a), bb = Buffer.from(b);
return ab.length === bb.length && crypto.timingSafeEqual(ab, bb);
}
export interface AuthResult { ok: true; token: string; username: string }
export interface AuthError { ok: false; code: string; message: string }
export class AccountStore {
private users = new Map<string, User>(); // key: lowercased username
private byId = new Map<string, User>();
private queue: Promise<unknown> = Promise.resolve();
private loaded = false;
constructor(private dir: string, private now: () => number = () => Date.now()) {}
private usersFile() { return path.join(this.dir, 'users.json'); }
private blobFile(id: string) { return path.join(this.dir, 'blobs', `${id}.json`); }
async load(): Promise<void> {
if (this.loaded) return;
this.loaded = true;
try {
const raw = await fs.readFile(this.usersFile(), 'utf8');
const arr = JSON.parse(raw) as User[];
for (const u of arr) { this.users.set(u.username.toLowerCase(), u); this.byId.set(u.id, u); }
} catch { /* no file yet */ }
}
private persist(): Promise<void> {
this.queue = this.queue.then(async () => {
await fs.mkdir(this.dir, { recursive: true });
const tmp = `${this.usersFile()}.tmp`;
await fs.writeFile(tmp, JSON.stringify([...this.users.values()]));
await fs.rename(tmp, this.usersFile());
}).catch(() => {});
return this.queue as Promise<void>;
}
private issueToken(u: User): string {
const token = crypto.randomBytes(32).toString('base64url');
u.tokens.push(sha256(token));
if (u.tokens.length > MAX_TOKENS) u.tokens = u.tokens.slice(-MAX_TOKENS);
u.updatedAt = this.now();
return token;
}
async register(username: string, password: string): Promise<AuthResult | AuthError> {
await this.load();
if (!USERNAME_RE.test(username)) return { ok: false, code: 'bad-username', message: 'Username must be 332 letters, digits, . _ or -.' };
if (typeof password !== 'string' || password.length < 8) return { ok: false, code: 'weak-password', message: 'Password must be at least 8 characters.' };
if (this.users.has(username.toLowerCase())) return { ok: false, code: 'taken', message: 'That username is taken.' };
const salt = crypto.randomBytes(16).toString('hex');
const u: User = { id: crypto.randomUUID(), username, salt, hash: hashPassword(password, salt), tokens: [], createdAt: this.now(), updatedAt: this.now() };
const token = this.issueToken(u);
this.users.set(username.toLowerCase(), u);
this.byId.set(u.id, u);
await this.persist();
return { ok: true, token, username: u.username };
}
async login(username: string, password: string): Promise<AuthResult | AuthError> {
await this.load();
const u = this.users.get((username ?? '').toLowerCase());
if (!u || !timingEqual(u.hash, hashPassword(password ?? '', u.salt))) return { ok: false, code: 'bad-credentials', message: 'Wrong username or password.' };
const token = this.issueToken(u);
await this.persist();
return { ok: true, token, username: u.username };
}
async userByToken(token: string | undefined): Promise<User | null> {
if (!token) return null;
await this.load();
const h = sha256(token);
for (const u of this.users.values()) if (u.tokens.includes(h)) return u;
return null;
}
async logout(token: string): Promise<void> {
const u = await this.userByToken(token);
if (!u) return;
u.tokens = u.tokens.filter((t) => t !== sha256(token));
await this.persist();
}
async saveBlob(id: string, blob: string): Promise<void> {
await fs.mkdir(path.join(this.dir, 'blobs'), { recursive: true });
const tmp = `${this.blobFile(id)}.tmp`;
await fs.writeFile(tmp, blob);
await fs.rename(tmp, this.blobFile(id));
}
async loadBlob(id: string): Promise<string | null> {
try { return await fs.readFile(this.blobFile(id), 'utf8'); } catch { return null; }
}
userCount(): number { return this.users.size; }
}
+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) => {