feat(01-03): Base64 codec + DoS-capped import + index re-exports + SaveDB interface refactor [GREEN]

- codec.ts: exportToBase64 / importFromBase64 via lz-string with
  MAX_IMPORT_BYTES=50MB DoS cap (T-01-02 in plan threat model); import
  validates against SaveEnvelopeSchema before returning. lz-string sync
  caveat documented per RESEARCH Pitfall 5 (Web Worker mitigation deferred
  to Phase 8 per CONTEXT D-09)
- index.ts: 14 public re-exports — the only entry point Phase 2 should
  import from. Includes the LocalStorageDBAdapter class so consumers can
  type-check the fallback path explicitly if needed

[Rule 1 - Bug] Build was failing because the original SaveDB type was a
union (IDBPDatabase<SaveDBSchema> | LocalStorageDBAdapter) — TypeScript
cannot resolve method calls through a union when each branch has
differently-shaped overloads ('no compatible signature' on every db.put).
Fixed by:
  - Defining SaveDB as a single common-contract interface that both
    backends MUST satisfy (get/put/delete/getAll/transaction with
    conditional-type RecordOf<S> return values)
  - Hoisting the canonical SavedRecord/SnapshotRecord/StoreName types
    into db-localstorage-adapter.ts (lower-level module) and re-exporting
    them from db.ts to avoid a circular import
  - Casting the idb-returned IDBPDatabase to SaveDB at the open-call
    boundary (the casts are isolated to openSaveDB; Phase 2 only sees
    the SaveDB interface)
  - Promoting SnapshotEntry to a type-alias of SnapshotRecord so
    snapshots.ts no longer redeclares the shape and can rely on
    canonical types

Tests: 36/36 pass under 'npx vitest run src/save/' (full suite incl
sentinel: 37/37). 'npm run build' exits 0 under TypeScript strict.
'npm run lint' is not invoked here because Plan 02 (eslint-firewall) has
not landed yet — the lint script will fail until it does, by design per
the Plan 01-01 SUMMARY ('Plan 02 owns it').
This commit is contained in:
2026-05-08 23:42:00 -04:00
parent bec0df1dc2
commit 2761bcc1e0
5 changed files with 195 additions and 30 deletions
+76
View File
@@ -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<T>(envelope: SaveEnvelope<T>): 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<unknown> {
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<unknown>;
}
+11 -2
View File
@@ -14,17 +14,26 @@ import type { SaveEnvelope } from './envelope';
* contract; IndexedDB is primary, localStorage is the fallback when IDB * contract; IndexedDB is primary, localStorage is the fallback when IDB
* throws. Phase 2's settings UI surfaces a "running on localStorage" * throws. Phase 2's settings UI surfaces a "running on localStorage"
* notice when this path triggers. * 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'; export type StoreName = 'saves' | 'save_snapshots';
/** A persisted save (singleton — only one slot in Phase 1, id = "main"). */
export interface SavedRecord { export interface SavedRecord {
id: string; /** Singleton key — Phase 1 ships one save slot only ("main"). */
id: 'main';
envelope: SaveEnvelope; envelope: SaveEnvelope;
/** ISO8601 timestamp of the write. */
savedAt: string; savedAt: string;
} }
/** A pre-migration snapshot kept under save_snapshots (last-N retention). */
export interface SnapshotRecord { export interface SnapshotRecord {
/** Composite key: `${schemaVersion}-${savedAt}-${entropy}`. */
id: string; id: string;
schemaVersion: number; schemaVersion: number;
savedAt: string; savedAt: string;
@@ -111,7 +120,7 @@ export class LocalStorageDBAdapter {
* nothing to commit. * nothing to commit.
*/ */
transaction<S extends StoreName>( transaction<S extends StoreName>(
store: S, _store: S,
_mode: 'readwrite' | 'readonly', _mode: 'readwrite' | 'readonly',
): { objectStore: (s: S) => LocalStorageObjectStore<S>; done: Promise<void> } { ): { objectStore: (s: S) => LocalStorageObjectStore<S>; done: Promise<void> } {
const adapter = this; const adapter = this;
+65 -22
View File
@@ -1,36 +1,76 @@
import { openDB, type IDBPDatabase } from 'idb'; import { openDB, type IDBPDatabase } from 'idb';
import type { SaveEnvelope } from './envelope'; import {
import { LocalStorageDBAdapter } from './db-localstorage-adapter'; LocalStorageDBAdapter,
type StoreName as SaveStoreName,
type RecordOf,
type SavedRecord,
type SnapshotRecord,
} from './db-localstorage-adapter';
export const SAVE_DB_NAME = 'tlg-save'; export const SAVE_DB_NAME = 'tlg-save';
const DB_VERSION = 1; const DB_VERSION = 1;
export interface SavedRecord { // Re-export the record types so Phase 2 consumers can import them from
/** Singleton key — Phase 1 ships one save slot only ("main"). */ // the canonical `./db` (or via index.ts) without reaching into the
id: 'main'; // adapter module.
envelope: SaveEnvelope; export type { SavedRecord, SnapshotRecord };
savedAt: string; // ISO8601 export type { SaveStoreName };
}
export interface SnapshotRecord {
/** Composite key: `${schemaVersion}-${savedAt}-${entropy}`. */
id: string;
schemaVersion: number;
savedAt: string;
envelope: SaveEnvelope;
}
export interface SaveDBSchema { export interface SaveDBSchema {
saves: { key: string; value: SavedRecord }; saves: { key: string; value: SavedRecord };
save_snapshots: { key: string; value: SnapshotRecord }; save_snapshots: { key: string; value: SnapshotRecord };
} }
/** What `db.transaction(...).objectStore(...)` exposes for one store. */
export interface SaveObjectStore<S extends SaveStoreName> {
put: (value: RecordOf<S>) => Promise<unknown>;
get: (key: string) => Promise<RecordOf<S> | undefined>;
delete: (key: string) => Promise<unknown>;
getAll: () => Promise<RecordOf<S>[]>;
}
export interface SaveTransaction<S extends SaveStoreName> {
objectStore: (s: S) => SaveObjectStore<S>;
done: Promise<void>;
}
/** /**
* Type union of the two backends IndexedDB primary, localStorage fallback. * Common contract that both backends (IndexedDB-primary and
* Phase 2's save consumer only calls the methods both backends implement * localStorage-fallback) MUST satisfy. We define this as a single
* (`get`, `put`, `delete`, `getAll`, `transaction`). * 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<SaveDBSchema> | LocalStorageDBAdapter; export interface SaveDB {
objectStoreNames: { contains: (s: string) => boolean };
get<S extends SaveStoreName>(
store: S,
key: string,
): Promise<RecordOf<S> | undefined>;
put<S extends SaveStoreName>(
store: S,
value: RecordOf<S>,
): Promise<unknown>;
delete(store: SaveStoreName, key: string): Promise<unknown>;
getAll<S extends SaveStoreName>(store: S): Promise<RecordOf<S>[]>;
transaction<S extends SaveStoreName>(
store: S,
mode: 'readwrite' | 'readonly',
): SaveTransaction<S>;
}
/**
* 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<unknown>` to absorb that).
*/
type IdbBackend = IDBPDatabase<SaveDBSchema>;
/** /**
* Opens the save DB. Tries IndexedDB first; on rejection (private mode, * Opens the save DB. Tries IndexedDB first; on rejection (private mode,
@@ -49,7 +89,7 @@ export type SaveDB = IDBPDatabase<SaveDBSchema> | LocalStorageDBAdapter;
*/ */
export async function openSaveDB(): Promise<SaveDB> { export async function openSaveDB(): Promise<SaveDB> {
try { try {
return await openDB<SaveDBSchema>(SAVE_DB_NAME, DB_VERSION, { const idb: IdbBackend = await openDB<SaveDBSchema>(SAVE_DB_NAME, DB_VERSION, {
upgrade(db) { upgrade(db) {
if (!db.objectStoreNames.contains('saves')) { if (!db.objectStoreNames.contains('saves')) {
db.createObjectStore('saves', { keyPath: 'id' }); db.createObjectStore('saves', { keyPath: 'id' });
@@ -59,6 +99,9 @@ export async function openSaveDB(): Promise<SaveDB> {
} }
}, },
}); });
// 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) { } catch (err) {
// IDB unavailable — fall back to localStorage. Phase 2's settings UI // IDB unavailable — fall back to localStorage. Phase 2's settings UI
// will surface a "running on localStorage" notice when this path // will surface a "running on localStorage" notice when this path
@@ -69,6 +112,6 @@ export async function openSaveDB(): Promise<SaveDB> {
'[save] IndexedDB unavailable, falling back to localStorage:', '[save] IndexedDB unavailable, falling back to localStorage:',
err, err,
); );
return new LocalStorageDBAdapter(); return new LocalStorageDBAdapter() as unknown as SaveDB;
} }
} }
+37
View File
@@ -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';
+6 -6
View File
@@ -1,12 +1,12 @@
import { openSaveDB } from './db'; import { openSaveDB } from './db';
import type { SnapshotRecord } from './db';
import type { SaveEnvelope } from './envelope'; import type { SaveEnvelope } from './envelope';
export interface SnapshotEntry { /**
id: string; * Public type for what listSnapshots returns. Structurally identical to
schemaVersion: number; * SnapshotRecord but exposed under a friendlier name for Phase 2's UI.
savedAt: string; */
envelope: SaveEnvelope; export type SnapshotEntry = SnapshotRecord;
}
/** /**
* Last-N pre-migration snapshot retention. CORE-08 mandates exactly 3. * Last-N pre-migration snapshot retention. CORE-08 mandates exactly 3.