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
+63
View File
@@ -0,0 +1,63 @@
import { buildBackup, restoreBackup } from '@/lib/io/backup';
/**
* Optional cloud account + backup sync against the same-origin server (/api).
* Local-first stays the default: this only does anything when the user signs in.
* Sync is a whole-backup push/pull (last-write-wins), not a live merge.
*/
const TOKEN_KEY = 'ttrpg-cloud-token';
const USER_KEY = 'ttrpg-cloud-user';
const base = () => `${location.origin}/api`;
export function cloudUsername(): string | null { return localStorage.getItem(USER_KEY); }
function token(): string | null { return localStorage.getItem(TOKEN_KEY); }
function setSession(t: string, username: string): void { localStorage.setItem(TOKEN_KEY, t); localStorage.setItem(USER_KEY, username); }
function clearSession(): void { localStorage.removeItem(TOKEN_KEY); localStorage.removeItem(USER_KEY); }
export class CloudError extends Error {}
async function authPost(path: string, body: unknown): Promise<{ token: string; username: string }> {
let res: Response;
try {
res = await fetch(`${base()}${path}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
} catch {
throw new CloudError('Could not reach the server.');
}
const data = (await res.json().catch(() => ({}))) as { token?: string; username?: string; message?: string };
if (!res.ok || !data.token) throw new CloudError(data.message ?? 'Request failed.');
setSession(data.token, data.username ?? '');
return { token: data.token, username: data.username ?? '' };
}
export function register(username: string, password: string) { return authPost('/register', { username, password }); }
export function login(username: string, password: string) { return authPost('/login', { username, password }); }
export async function logout(): Promise<void> {
const t = token();
if (t) { try { await fetch(`${base()}/logout`, { method: 'POST', headers: { authorization: `Bearer ${t}` } }); } catch { /* ignore */ } }
clearSession();
}
/** Upload this device's full backup to the cloud. */
export async function pushBackup(): Promise<number> {
const t = token();
if (!t) throw new CloudError('Not signed in.');
const blob = JSON.stringify(await buildBackup());
const res = await fetch(`${base()}/save`, { method: 'PUT', headers: { 'content-type': 'application/json', authorization: `Bearer ${t}` }, body: JSON.stringify({ blob }) });
if (res.status === 401) { clearSession(); throw new CloudError('Session expired — sign in again.'); }
if (!res.ok) throw new CloudError('Upload failed.');
return blob.length;
}
/** Replace this device's data with the cloud copy. Returns false if there's no cloud save yet. */
export async function pullBackup(): Promise<boolean> {
const t = token();
if (!t) throw new CloudError('Not signed in.');
const res = await fetch(`${base()}/save`, { headers: { authorization: `Bearer ${t}` } });
if (res.status === 404) return false;
if (res.status === 401) { clearSession(); throw new CloudError('Session expired — sign in again.'); }
if (!res.ok) throw new CloudError('Download failed.');
await restoreBackup(await res.text());
return true;
}
+13 -2
View File
@@ -1,6 +1,7 @@
import { characterSchema, type Character } from '@/lib/schemas';
import { newId } from '@/lib/ids';
import { downloadJson, safeFilename } from './file';
import { isPathbuilder, pathbuilderToCharacterFields } from './pathbuilder';
const FORMAT = 'ttrpg-manager:character';
const VERSION = 1;
@@ -33,6 +34,18 @@ export function parseCharacterImport(text: string, campaignId: string): Characte
throw new CharacterImportError('That file is not valid JSON.');
}
const now = new Date().toISOString();
// Pathbuilder 2e export → convert to our PF2e character shape.
if (isPathbuilder(raw)) {
const result = characterSchema.safeParse({
...pathbuilderToCharacterFields(raw.build),
id: newId(), campaignId, createdAt: now, updatedAt: now,
});
if (!result.success) throw new CharacterImportError(`Pathbuilder import failed: ${result.error.issues[0]?.message ?? 'unknown error'}`);
return result.data;
}
const candidate =
raw && typeof raw === 'object' && 'character' in raw
? (raw as { character: unknown }).character
@@ -41,8 +54,6 @@ export function parseCharacterImport(text: string, campaignId: string): Characte
if (!candidate || typeof candidate !== 'object') {
throw new CharacterImportError('No character data found in that file.');
}
const now = new Date().toISOString();
const result = characterSchema.safeParse({
...(candidate as Record<string, unknown>),
id: newId(),
+38
View File
@@ -0,0 +1,38 @@
import { describe, it, expect } from 'vitest';
import { isPathbuilder, pathbuilderToCharacterFields } from './pathbuilder';
import { parseCharacterImport } from './character';
const file = {
build: {
name: 'Valeros', class: 'Fighter', level: 3, ancestry: 'Human', background: 'Soldier',
abilities: { str: 18, dex: 14, con: 14, int: 10, wis: 12, cha: 10 },
attributes: { ancestryhp: 8, classhp: 10, bonushp: 0, bonushpPerLevel: 0 },
proficiencies: { perception: 4, fortitude: 4, reflex: 4, will: 2, athletics: 4, intimidation: 2 },
},
};
describe('pathbuilder import', () => {
it('detects and converts a build', () => {
expect(isPathbuilder(file)).toBe(true);
expect(isPathbuilder({ build: {} })).toBe(false);
const f = pathbuilderToCharacterFields(file.build);
expect(f.system).toBe('pf2e');
expect(f.name).toBe('Valeros');
expect(f.className).toBe('Fighter');
expect(f.level).toBe(3);
expect(f.abilities!.str).toBe(18);
expect(f.hp!.max).toBe(44); // 8 ancestry + (10 class + 2 con)*3
expect(f.saveRanks!.con).toBe('expert');
expect(f.saveRanks!.wis).toBe('trained');
expect(f.skillRanks!.athletics).toBe('expert');
expect(f.perceptionRank).toBe('expert');
});
it('parseCharacterImport handles a Pathbuilder file end-to-end', () => {
const c = parseCharacterImport(JSON.stringify(file), 'cmp1');
expect(c.name).toBe('Valeros');
expect(c.system).toBe('pf2e');
expect(c.campaignId).toBe('cmp1');
expect(c.hp.max).toBe(44);
});
});
+52
View File
@@ -0,0 +1,52 @@
import type { Character } from '@/lib/schemas';
/** Convert a Pathbuilder 2e export (build JSON) into our PF2e character fields. */
const RANK: Record<number, Character['perceptionRank']> = { 0: 'untrained', 2: 'trained', 4: 'expert', 6: 'master', 8: 'legendary' };
const PF2E_SKILLS = ['acrobatics', 'arcana', 'athletics', 'crafting', 'deception', 'diplomacy', 'intimidation', 'medicine', 'nature', 'occultism', 'performance', 'religion', 'society', 'stealth', 'survival', 'thievery'];
export interface PathbuilderBuild {
name?: string; class?: string; level?: number; ancestry?: string; heritage?: string; background?: string;
abilities?: Record<string, number>;
attributes?: { ancestryhp?: number; classhp?: number; bonushp?: number; bonushpPerLevel?: number };
proficiencies?: Record<string, number>;
}
export function isPathbuilder(raw: unknown): raw is { build: PathbuilderBuild } {
return !!raw && typeof raw === 'object' && 'build' in raw && !!(raw as { build?: { class?: unknown } }).build?.class;
}
const rank = (v: number | undefined): Character['perceptionRank'] => RANK[v ?? 0] ?? 'untrained';
export function pathbuilderToCharacterFields(b: PathbuilderBuild): Partial<Character> {
const a = b.abilities ?? {};
const abilities = { str: a.str ?? 10, dex: a.dex ?? 10, con: a.con ?? 10, int: a.int ?? 10, wis: a.wis ?? 10, cha: a.cha ?? 10 };
const level = Math.max(1, Math.min(20, b.level ?? 1));
const conMod = Math.floor((abilities.con - 10) / 2);
const at = b.attributes ?? {};
const hpMax = Math.max(1, (at.ancestryhp ?? 0) + (at.bonushp ?? 0) + ((at.classhp ?? 8) + (at.bonushpPerLevel ?? 0) + conMod) * level);
const prof = b.proficiencies ?? {};
const saveRanks: Record<string, Character['perceptionRank']> = {};
if (prof.fortitude) saveRanks.con = rank(prof.fortitude);
if (prof.reflex) saveRanks.dex = rank(prof.reflex);
if (prof.will) saveRanks.wis = rank(prof.will);
const skillRanks: Record<string, Character['perceptionRank']> = {};
for (const s of PF2E_SKILLS) if (prof[s]) skillRanks[s] = rank(prof[s]);
return {
system: 'pf2e',
kind: 'pc',
name: b.name?.trim() || 'Imported Character',
className: b.class ?? '',
ancestry: b.ancestry ?? '',
level,
abilities,
hp: { current: hpMax, max: hpMax, temp: 0 },
saveRanks: saveRanks as Character['saveRanks'],
skillRanks: skillRanks as Character['skillRanks'],
perceptionRank: rank(prof.perception) === 'untrained' ? 'trained' : rank(prof.perception),
...(b.background ? { notes: `Background: ${b.background}${b.heritage ? `\nHeritage: ${b.heritage}` : ''}` } : {}),
};
}