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
+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(),