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
|
* 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
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { 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.
|
||||||
|
|||||||
Reference in New Issue
Block a user