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:
@@ -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>;
|
||||
}
|
||||
@@ -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<S extends StoreName>(
|
||||
store: S,
|
||||
_store: S,
|
||||
_mode: 'readwrite' | 'readonly',
|
||||
): { objectStore: (s: S) => LocalStorageObjectStore<S>; done: Promise<void> } {
|
||||
const adapter = this;
|
||||
|
||||
+65
-22
@@ -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<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.
|
||||
* 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<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,
|
||||
@@ -49,7 +89,7 @@ export type SaveDB = IDBPDatabase<SaveDBSchema> | LocalStorageDBAdapter;
|
||||
*/
|
||||
export async function openSaveDB(): Promise<SaveDB> {
|
||||
try {
|
||||
return await openDB<SaveDBSchema>(SAVE_DB_NAME, DB_VERSION, {
|
||||
const idb: IdbBackend = await openDB<SaveDBSchema>(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<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) {
|
||||
// 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<SaveDB> {
|
||||
'[save] IndexedDB unavailable, falling back to localStorage:',
|
||||
err,
|
||||
);
|
||||
return new LocalStorageDBAdapter();
|
||||
return new LocalStorageDBAdapter() as unknown as SaveDB;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user