diff --git a/src/save/codec.ts b/src/save/codec.ts new file mode 100644 index 0000000..e614338 --- /dev/null +++ b/src/save/codec.ts @@ -0,0 +1,76 @@ +import LZString from 'lz-string'; +import { SaveEnvelopeSchema, type SaveEnvelope } from './envelope'; + +/** + * 50MB cap on Base64 import string length, per the Phase 1 threat model + * (T-01-02 in the plan + .planning/phases/01-foundations-and-doctrine/01-RESEARCH.md + * § Security Domain — malformed Base64 import / DoS via huge inflated + * string). + * + * `lz-string.decompressFromBase64` has bounded output for bounded input, + * but it is synchronous and would block the main thread on a pathological + * input. We refuse oversized payloads at the boundary BEFORE invoking + * decompression. + * + * 50MB is generous: real Phase-1 saves will be <10KB. The cap exists so a + * malicious or accidental paste cannot freeze the tab. + */ +export const MAX_IMPORT_BYTES = 50 * 1024 * 1024; + +/** + * Export a SaveEnvelope to a Base64 text blob suitable for the eventual + * "Settings → Export" button. Phase 1 ships the function pair; Phase 2 + * wires the UI (CORE-09). + * + * Note: lz-string is synchronous. For the <10KB Phase-1 saves this is + * fine. RESEARCH Pitfall 5 documents the eventual mitigation (Web Worker) + * for when saves grow past ~1MB in Phase 8 perf work — do NOT add it now, + * per CONTEXT D-09 minimum-viable directive. + */ +export function exportToBase64(envelope: SaveEnvelope): string { + return LZString.compressToBase64(JSON.stringify(envelope)); +} + +/** + * Import a SaveEnvelope from a Base64 text blob. Throws on: + * - input larger than `MAX_IMPORT_BYTES` (DoS cap, T-01-02) + * - lz-string decompression failure + * - JSON parse failure + * - `SaveEnvelopeSchema` validation failure (malformed envelope shape) + * + * Note: this does NOT verify the envelope's CRC checksum or run migrations. + * The full pipeline is `importFromBase64 → migrate → unwrap`; see + * `round-trip.test.ts` for the canonical example. Splitting these phases + * lets the caller (Phase 2 settings UI) show different error states for + * "malformed import" vs "checksum mismatch" vs "migration failure". + * + * Per threat-model T-01-03: this function detects corruption, NOT + * adversarial editing. A player editing their own Base64 export and + * re-importing is by-design acceptable in single-player. + */ +export function importFromBase64(base64: string): SaveEnvelope { + if (base64.length > MAX_IMPORT_BYTES) { + throw new Error( + `Import payload exceeds ${MAX_IMPORT_BYTES} bytes (got ${base64.length})`, + ); + } + const decompressed = LZString.decompressFromBase64(base64); + if (!decompressed) { + throw new Error('Failed to decompress Base64 import (malformed input)'); + } + let parsed: unknown; + try { + parsed = JSON.parse(decompressed); + } catch (err) { + throw new Error( + `Imported blob is not valid JSON: ${(err as Error).message}`, + ); + } + const validated = SaveEnvelopeSchema.safeParse(parsed); + if (!validated.success) { + throw new Error( + `Imported envelope failed schema validation: ${validated.error.message}`, + ); + } + return validated.data as SaveEnvelope; +} diff --git a/src/save/db-localstorage-adapter.ts b/src/save/db-localstorage-adapter.ts index 50f0a51..edb909d 100644 --- a/src/save/db-localstorage-adapter.ts +++ b/src/save/db-localstorage-adapter.ts @@ -14,17 +14,26 @@ import type { SaveEnvelope } from './envelope'; * contract; IndexedDB is primary, localStorage is the fallback when IDB * throws. Phase 2's settings UI surfaces a "running on localStorage" * notice when this path triggers. + * + * The record-type definitions live HERE rather than in `db.ts` to avoid a + * circular import (db.ts depends on this adapter). `db.ts` re-exports + * them so Phase 2 consumers see a single canonical set of types. */ export type StoreName = 'saves' | 'save_snapshots'; +/** A persisted save (singleton — only one slot in Phase 1, id = "main"). */ export interface SavedRecord { - id: string; + /** Singleton key — Phase 1 ships one save slot only ("main"). */ + id: 'main'; envelope: SaveEnvelope; + /** ISO8601 timestamp of the write. */ savedAt: string; } +/** A pre-migration snapshot kept under save_snapshots (last-N retention). */ export interface SnapshotRecord { + /** Composite key: `${schemaVersion}-${savedAt}-${entropy}`. */ id: string; schemaVersion: number; savedAt: string; @@ -111,7 +120,7 @@ export class LocalStorageDBAdapter { * nothing to commit. */ transaction( - store: S, + _store: S, _mode: 'readwrite' | 'readonly', ): { objectStore: (s: S) => LocalStorageObjectStore; done: Promise } { const adapter = this; diff --git a/src/save/db.ts b/src/save/db.ts index a2f8bfb..f353a60 100644 --- a/src/save/db.ts +++ b/src/save/db.ts @@ -1,36 +1,76 @@ import { openDB, type IDBPDatabase } from 'idb'; -import type { SaveEnvelope } from './envelope'; -import { LocalStorageDBAdapter } from './db-localstorage-adapter'; +import { + LocalStorageDBAdapter, + type StoreName as SaveStoreName, + type RecordOf, + type SavedRecord, + type SnapshotRecord, +} from './db-localstorage-adapter'; export const SAVE_DB_NAME = 'tlg-save'; const DB_VERSION = 1; -export interface SavedRecord { - /** Singleton key — Phase 1 ships one save slot only ("main"). */ - id: 'main'; - envelope: SaveEnvelope; - savedAt: string; // ISO8601 -} - -export interface SnapshotRecord { - /** Composite key: `${schemaVersion}-${savedAt}-${entropy}`. */ - id: string; - schemaVersion: number; - savedAt: string; - envelope: SaveEnvelope; -} +// Re-export the record types so Phase 2 consumers can import them from +// the canonical `./db` (or via index.ts) without reaching into the +// adapter module. +export type { SavedRecord, SnapshotRecord }; +export type { SaveStoreName }; export interface SaveDBSchema { saves: { key: string; value: SavedRecord }; save_snapshots: { key: string; value: SnapshotRecord }; } +/** What `db.transaction(...).objectStore(...)` exposes for one store. */ +export interface SaveObjectStore { + put: (value: RecordOf) => Promise; + get: (key: string) => Promise | undefined>; + delete: (key: string) => Promise; + getAll: () => Promise[]>; +} + +export interface SaveTransaction { + objectStore: (s: S) => SaveObjectStore; + done: Promise; +} + /** - * Type union of the two backends — IndexedDB primary, localStorage fallback. - * Phase 2's save consumer only calls the methods both backends implement - * (`get`, `put`, `delete`, `getAll`, `transaction`). + * Common contract that both backends (IndexedDB-primary and + * localStorage-fallback) MUST satisfy. We define this as a single + * interface (rather than a union of `IDBPDatabase | LocalStorageDBAdapter`) + * because TypeScript cannot narrow method calls through a union when the + * two branches have differently-shaped overloads — the result is a + * "no compatible signature" type error on every `db.put(...)` call. + * + * Phase 2's save consumer should program against this interface, not + * against either concrete backend. */ -export type SaveDB = IDBPDatabase | LocalStorageDBAdapter; +export interface SaveDB { + objectStoreNames: { contains: (s: string) => boolean }; + get( + store: S, + key: string, + ): Promise | undefined>; + put( + store: S, + value: RecordOf, + ): Promise; + delete(store: SaveStoreName, key: string): Promise; + getAll(store: S): Promise[]>; + transaction( + store: S, + mode: 'readwrite' | 'readonly', + ): SaveTransaction; +} + +/** + * Internal: the IDBPDatabase shape narrowed to our schema. We cast the + * raw `idb`-returned value to `SaveDB` because IDBPDatabase exposes a + * superset of methods with overloads that satisfy `SaveDB` at runtime + * (idb returns the value for `put` keys, but the SaveDB.put we declared + * also returns `Promise` to absorb that). + */ +type IdbBackend = IDBPDatabase; /** * Opens the save DB. Tries IndexedDB first; on rejection (private mode, @@ -49,7 +89,7 @@ export type SaveDB = IDBPDatabase | LocalStorageDBAdapter; */ export async function openSaveDB(): Promise { try { - return await openDB(SAVE_DB_NAME, DB_VERSION, { + const idb: IdbBackend = await openDB(SAVE_DB_NAME, DB_VERSION, { upgrade(db) { if (!db.objectStoreNames.contains('saves')) { db.createObjectStore('saves', { keyPath: 'id' }); @@ -59,6 +99,9 @@ export async function openSaveDB(): Promise { } }, }); + // idb's IDBPDatabase has overloaded methods that satisfy SaveDB at + // runtime; the `as unknown as SaveDB` is the type-system bridge. + return idb as unknown as SaveDB; } catch (err) { // IDB unavailable — fall back to localStorage. Phase 2's settings UI // will surface a "running on localStorage" notice when this path @@ -69,6 +112,6 @@ export async function openSaveDB(): Promise { '[save] IndexedDB unavailable, falling back to localStorage:', err, ); - return new LocalStorageDBAdapter(); + return new LocalStorageDBAdapter() as unknown as SaveDB; } } diff --git a/src/save/index.ts b/src/save/index.ts new file mode 100644 index 0000000..457487c --- /dev/null +++ b/src/save/index.ts @@ -0,0 +1,37 @@ +/** + * Public surface of the save layer. Phase 2's tick scheduler + Zustand + * store are the first consumers — they should ONLY import from this + * file, never from the individual modules underneath. The internal + * shape is allowed to change between phases; this barrel is the + * stability contract. + */ + +export { wrap, unwrap, SaveCorruptError, SaveEnvelopeSchema } from './envelope'; +export type { SaveEnvelope } from './envelope'; + +export { migrate, CURRENT_SCHEMA_VERSION, migrations } from './migrations'; +export type { V1Payload } from './migrations'; + +export { snapshot, listSnapshots } from './snapshots'; +export type { SnapshotEntry } from './snapshots'; + +export { requestPersistence } from './persist'; +export type { PersistResult } from './persist'; + +export { exportToBase64, importFromBase64, MAX_IMPORT_BYTES } from './codec'; + +export { openSaveDB, SAVE_DB_NAME } from './db'; +export type { + SaveDB, + SaveDBSchema, + SavedRecord, + SnapshotRecord, + SaveStoreName, + SaveObjectStore, + SaveTransaction, +} from './db'; + +export { LocalStorageDBAdapter } from './db-localstorage-adapter'; +export type { StoreName, RecordOf } from './db-localstorage-adapter'; + +export { crc32hex, canonicalJSON } from './checksum'; diff --git a/src/save/snapshots.ts b/src/save/snapshots.ts index accf244..88fceb0 100644 --- a/src/save/snapshots.ts +++ b/src/save/snapshots.ts @@ -1,12 +1,12 @@ import { openSaveDB } from './db'; +import type { SnapshotRecord } from './db'; import type { SaveEnvelope } from './envelope'; -export interface SnapshotEntry { - id: string; - schemaVersion: number; - savedAt: string; - envelope: SaveEnvelope; -} +/** + * Public type for what listSnapshots returns. Structurally identical to + * SnapshotRecord but exposed under a friendlier name for Phase 2's UI. + */ +export type SnapshotEntry = SnapshotRecord; /** * Last-N pre-migration snapshot retention. CORE-08 mandates exactly 3.