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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 3–32 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
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user