--- phase: 01 plan: 03 type: execute wave: 2 depends_on: [01-01] files_modified: - src/save/checksum.ts - src/save/checksum.test.ts - src/save/envelope.ts - src/save/envelope.test.ts - src/save/migrations.ts - src/save/migrations.test.ts - src/save/db.ts - src/save/db-localstorage-adapter.ts - src/save/db.test.ts - src/save/snapshots.ts - src/save/snapshots.test.ts - src/save/persist.ts - src/save/persist.test.ts - src/save/codec.ts - src/save/round-trip.test.ts - src/save/index.ts autonomous: true requirements: [CORE-04, CORE-05, CORE-06, CORE-07, CORE-08, CORE-09] must_haves: truths: - "A save envelope `{schemaVersion, payload, checksum}` can be wrapped from a payload + version, unwrapped back, and `unwrap` throws on checksum mismatch (CORE-06)" - "Canonical-JSON serialization sorts object keys recursively so the same payload always produces the same checksum across runs" - "A synthetic v0 payload `{garden: []}` migrates to the v1 shape (garden tiles, plants, harvestedFragmentIds, lastTickAt, settings) via the migration registry (CORE-07, per CONTEXT D-04 + D-05)" - "After 5 successive `snapshot()` calls, exactly 3 newest entries remain in the `save_snapshots` IndexedDB store (CORE-08)" - "A v0 envelope can be Base64-exported via lz-string, Base64-imported into a fresh DB, migrated through the chain, and unwrapped to the original payload (CORE-09)" - "`requestPersistence()` returns `{granted: boolean, apiAvailable: boolean}` and handles missing `navigator.storage.persist` gracefully (CORE-05)" - "An IndexedDB save round-trips: `openSaveDB() → put(envelope) → get() → equals original` (CORE-04)" - "CORE-04 IndexedDB-primary + localStorage-fallback both round-trip via Vitest: when `openDB` rejects, the same `get`/`put`/`delete` interface is served by `LocalStorageDBAdapter`, and a stub-injected IDB failure exercises the fallback path" artifacts: - path: src/save/checksum.ts provides: "crc32hex(string) → 8-char lowercase hex CRC-32; canonicalJSON(value) → recursively-key-sorted JSON string" exports: ["crc32hex", "canonicalJSON"] - path: src/save/envelope.ts provides: "wrap(payload, schemaVersion), unwrap(env), SaveEnvelope type, SaveCorruptError class, SaveEnvelopeSchema (Zod)" exports: ["wrap", "unwrap", "SaveEnvelope", "SaveCorruptError", "SaveEnvelopeSchema"] - path: src/save/migrations.ts provides: "migrate(payload, fromVersion) → {payload, toVersion}; CURRENT_SCHEMA_VERSION constant; migrations registry with v0→v1 synthetic demo" exports: ["migrate", "CURRENT_SCHEMA_VERSION", "migrations"] - path: src/save/db.ts provides: "openSaveDB() → SaveDB (IDBPDatabase or LocalStorageDBAdapter); SAVE_DB_NAME constant; two object stores: 'saves' (singleton) + 'save_snapshots' (keyed by id); falls back to LocalStorageDBAdapter on IDB failure (CORE-04)" exports: ["openSaveDB", "SAVE_DB_NAME"] - path: src/save/db-localstorage-adapter.ts provides: "LocalStorageDBAdapter — thin localStorage-backed implementation of the same minimal interface as the IDB DB (get/put/delete on saves + save_snapshots), keyed under tlg.saves.* / tlg.save_snapshots.* (CORE-04 fallback path)" exports: ["LocalStorageDBAdapter"] - path: src/save/snapshots.ts provides: "snapshot(envelope), listSnapshots() — last-3 retention; SnapshotEntry type" exports: ["snapshot", "listSnapshots", "SnapshotEntry"] - path: src/save/persist.ts provides: "requestPersistence() → Promise<{granted, apiAvailable}>" exports: ["requestPersistence", "PersistResult"] - path: src/save/codec.ts provides: "exportToBase64(envelope), importFromBase64(base64) — lz-string round-trip with 50MB DoS cap" exports: ["exportToBase64", "importFromBase64", "MAX_IMPORT_BYTES"] - path: src/save/index.ts provides: "Public re-exports for Phase 2 consumption" key_links: - from: src/save/envelope.ts to: src/save/checksum.ts via: "import { crc32hex, canonicalJSON } from './checksum'" pattern: "import \\{ crc32hex, canonicalJSON \\} from './checksum'" - from: src/save/migrations.ts to: "synthetic v0 payload {garden: []}" via: "migrations[1] receives {garden: any[]} and produces v1 shape per CONTEXT D-04" pattern: "garden:\\s*\\{\\s*tiles:" - from: src/save/snapshots.ts to: src/save/db.ts via: "openSaveDB() — uses 'save_snapshots' object store" pattern: "save_snapshots" - from: src/save/db.ts to: src/save/db-localstorage-adapter.ts via: "openSaveDB wraps openDB() in try/catch and returns LocalStorageDBAdapter when IDB rejects (CORE-04 fallback)" pattern: "LocalStorageDBAdapter" - from: src/save/round-trip.test.ts to: "src/save/codec.ts + envelope.ts + migrations.ts" via: "Full pipeline: wrap → exportToBase64 → importFromBase64 → migrate → unwrap" pattern: "exportToBase64.*importFromBase64.*migrate.*unwrap" --- **Plan 03 modifies 16 files across 3 tasks — at the upper edge of the per-plan budget.** Recommend `/clear` between tasks if executor context fills (after the Task 1 commit and after the Task 2 commit). Tasks are independently committable; the Wave 2 frontmatter has no other plan depending on intermediate state from this plan, so a context reset between tasks is safe. Build the load-bearing save layer for the entire game: envelope `{schemaVersion, payload, checksum}` with CRC-32 over canonical JSON, a forward-only migration chain seeded with a synthetic v0→v1 demo migration, an `idb`-wrapped IndexedDB DB with two object stores (`saves` + `save_snapshots`) **plus a thin localStorage fallback adapter for CORE-04 when IndexedDB is unavailable** (private mode, blocked by browser, quota exceeded), last-3 pre-migration snapshot retention, `navigator.storage.persist()` with respectful surfacing of `false`, and Base64 export/import via lz-string with a 50MB DoS cap on import. Every behavior is covered by a Vitest unit test, plus a single round-trip test that exercises the full pipeline end-to-end. Purpose: Phase 2's first feature commit will write the first real save — and Phase 4 will ship the first real `migrate_v1_to_v2`. If the framework here is wrong, every subsequent Season's save migration is broken. CONTEXT D-04 + D-05 + D-06 lock the shape: minimal v1 payload (only what Phase 2 will write), synthetic v0→v1 demo migration to prove the chain, envelope shape locked from CLAUDE.md. RESEARCH § Patterns 1, 2, 3 + Pitfalls 3 (canonical JSON), 5 (lz-string sync caveat), 7 (real migration registry test) provide concrete code. REQUIREMENTS.md CORE-04 ("with localStorage fallback") + ROADMAP success criterion #2 ("with localStorage fallback and `navigator.storage.persist()`") require the fallback to ship in Phase 1; this plan satisfies that with a ~30-LoC adapter + one stub-injected Vitest test. Output: A complete save subsystem under `src/save/` with one entry point (`src/save/index.ts`), 7 implementation files + 6 test files + 1 codec round-trip test, all Vitest tests passing in the happy-dom environment. @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md @.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/01-foundations-and-doctrine/01-CONTEXT.md @.planning/phases/01-foundations-and-doctrine/01-RESEARCH.md @.planning/phases/01-foundations-and-doctrine/01-01-SUMMARY.md @CLAUDE.md @.planning/research/ARCHITECTURE.md @.planning/research/PITFALLS.md From src/save/envelope.ts (this plan creates): ```typescript export interface SaveEnvelope { schemaVersion: number; payload: T; checksum: string; // 8-char lowercase hex CRC-32 over canonicalJSON(payload) } export class SaveCorruptError extends Error { /* expected, actual */ } export function wrap(payload: T, schemaVersion: number): SaveEnvelope; export function unwrap(env: SaveEnvelope): T; // throws SaveCorruptError on mismatch ``` From src/save/migrations.ts: ```typescript export const CURRENT_SCHEMA_VERSION = 1; export const migrations: Record unknown>; export function migrate(payload: unknown, fromVersion: number): { payload: unknown; toVersion: number }; ``` From src/save/codec.ts: ```typescript export const MAX_IMPORT_BYTES = 50 * 1024 * 1024; // 50MB DoS cap per Phase 1 threat model export function exportToBase64(env: SaveEnvelope): string; export function importFromBase64(base64: string): SaveEnvelope; // throws on >MAX or invalid ``` Task 1: Checksum + envelope + migrations (the pure-function core) src/save/checksum.ts, src/save/checksum.test.ts, src/save/envelope.ts, src/save/envelope.test.ts, src/save/migrations.ts, src/save/migrations.test.ts - .planning/phases/01-foundations-and-doctrine/01-RESEARCH.md § "Pattern 1: Save Envelope" (verbatim code), § "Pattern 2: Migration Registry" (verbatim code), § "Common Pitfalls — Pitfall 3: JSON key ordering breaks checksums across runs" (canonical-JSON requirement), § "Common Pitfalls — Pitfall 7: Synthetic v0→v1 migration test that doesn't actually exercise the registry" (the 5 required Vitest assertions for CORE-07) - .planning/phases/01-foundations-and-doctrine/01-CONTEXT.md (D-04 — minimal v1 payload shape: garden tiles, plant growth data, harvested fragment IDs, lastTickAt, basic settings; D-05 — synthetic v0→v1 demo migration; D-06 — envelope shape locked, checksum/registry Claude's discretion) - CLAUDE.md "Code Style" — TypeScript strict, no `any` in production; `BigQty` is Phase 2 (do NOT pre-create) - **checksum.ts:** - Test 1: `crc32hex('hello')` returns the same 8-char lowercase hex string on every call (deterministic). - Test 2: `crc32hex('hello')` and `crc32hex('world')` differ. - Test 3: `canonicalJSON({b:1, a:2})` and `canonicalJSON({a:2, b:1})` are byte-identical. - Test 4: `canonicalJSON` recursively sorts nested object keys. - Test 5: `canonicalJSON` preserves array order (arrays are NOT sorted). - **envelope.ts:** - Test 1: `wrap({foo: 'bar'}, 1)` returns `{schemaVersion: 1, payload: {foo: 'bar'}, checksum: <8-char hex>}`. - Test 2: `unwrap(wrap(p, 1))` deep-equals `p` for several payload shapes. - Test 3: `unwrap` with a tampered checksum throws `SaveCorruptError` with `expected` and `actual` fields. - Test 4: `unwrap` with a tampered payload (checksum mismatched) throws `SaveCorruptError`. - Test 5: `SaveEnvelopeSchema.safeParse` rejects malformed envelopes (missing keys, non-hex checksum). - **migrations.ts:** - Test 1 (the load-bearing one per Pitfall 7): `migrate({garden: [{id: 'tile-1'}]}, 0)` returns `{payload: {garden: {tiles: [{id: 'tile-1'}]}, plants: [], harvestedFragmentIds: [], lastTickAt: , settings: {...}}, toVersion: 1}`. - Test 2: `migrate(, 1)` is a no-op (returns `{payload, toVersion: 1}` unchanged). - Test 3: `migrate(, 99)` throws (no migration to a future version). - Test 4: `migrate(, -1)` throws (no migration registered). - Test 5: `migrations[1]` is invoked exactly once when migrating from v0 to v1 (use a spy/mock or count by replacing `migrations[1]` and asserting call count). - Test 6: `CURRENT_SCHEMA_VERSION === 1` (sanity). Write each file using the Write tool, copying the patterns from RESEARCH.md verbatim where they exist. Pure-function core — no I/O, no async. **Step 1 — `src/save/checksum.ts`** (per RESEARCH Pattern 1 + Pitfall 3): ```typescript import CRC32 from 'crc-32'; /** * 8-char lowercase hex CRC-32 of the input string. * crc-32 returns a signed 32-bit integer; we mask to unsigned and pad. * Used by envelope.wrap/unwrap to detect save corruption. */ export function crc32hex(input: string): string { const signed = CRC32.str(input); const unsigned = signed >>> 0; // coerce to uint32 return unsigned.toString(16).padStart(8, '0'); } /** * Deterministic JSON serialization with recursively-sorted object keys. * Required because checksum stability depends on stable key order across * V8 / SpiderMonkey / JavaScriptCore runs and across migration round-trips * (per .planning/phases/01-foundations-and-doctrine/01-RESEARCH.md Pitfall 3). * Arrays are NOT sorted (their order is meaningful). */ export function canonicalJSON(value: unknown): string { return JSON.stringify(value, (_key, val) => { if (val && typeof val === 'object' && !Array.isArray(val)) { return Object.fromEntries( Object.entries(val as Record).sort(([a], [b]) => a.localeCompare(b)), ); } return val; }); } ``` Per RESEARCH Open Question #1, hand-rolled sorted-key recursion is recommended (no `json-stable-stringify` dep). The `>>> 0` coercion converts crc-32's signed return to unsigned per the SheetJS docs. **Step 2 — `src/save/checksum.test.ts`** with all 5 behaviors above. Concrete shape: ```typescript import { describe, it, expect } from 'vitest'; import { crc32hex, canonicalJSON } from './checksum'; describe('crc32hex', () => { it('is deterministic', () => { expect(crc32hex('hello')).toBe(crc32hex('hello')); }); it('returns 8-char lowercase hex', () => { expect(crc32hex('hello')).toMatch(/^[0-9a-f]{8}$/); }); it('differs for different inputs', () => { expect(crc32hex('hello')).not.toBe(crc32hex('world')); }); }); describe('canonicalJSON', () => { it('produces byte-identical output for objects with same keys in any order', () => { expect(canonicalJSON({ b: 1, a: 2 })).toBe(canonicalJSON({ a: 2, b: 1 })); }); it('sorts nested object keys recursively', () => { expect(canonicalJSON({ b: { z: 1, a: 2 }, a: 1 })) .toBe(canonicalJSON({ a: 1, b: { a: 2, z: 1 } })); }); it('does NOT sort arrays', () => { expect(canonicalJSON([3, 1, 2])).toBe('[3,1,2]'); }); }); ``` **Step 3 — `src/save/envelope.ts`** (per RESEARCH Pattern 1 verbatim, with Zod schema added): ```typescript import { z } from 'zod'; import { crc32hex, canonicalJSON } from './checksum'; export const SaveEnvelopeSchema = z.object({ schemaVersion: z.number().int().nonnegative(), payload: z.unknown(), checksum: z.string().regex(/^[0-9a-f]{8}$/), }); export type SaveEnvelope = z.infer & { payload: T }; export class SaveCorruptError extends Error { readonly name = 'SaveCorruptError'; constructor(public readonly expected: string, public readonly actual: string) { super(`Save checksum mismatch: expected ${expected}, got ${actual}`); } } export function wrap(payload: T, schemaVersion: number): SaveEnvelope { return { schemaVersion, payload, checksum: crc32hex(canonicalJSON(payload)), }; } export function unwrap(env: SaveEnvelope): T { const expected = crc32hex(canonicalJSON(env.payload)); if (expected !== env.checksum) { throw new SaveCorruptError(env.checksum, expected); } return env.payload as T; } ``` Per CONTEXT D-04: zero (the synthetic v0) is a valid `schemaVersion`, so the Zod refinement uses `nonnegative` not `positive`. RESEARCH Pattern 1's example uses `positive` but that conflicts with D-05's synthetic-v0 requirement — use `nonnegative`. **Step 4 — `src/save/envelope.test.ts`** with all 5 behaviors above. **Step 5 — `src/save/migrations.ts`** (per RESEARCH Pattern 2 verbatim): ```typescript type Migration = (payload: unknown) => unknown; export const CURRENT_SCHEMA_VERSION = 1; /** * Forward-only migration chain. Each entry migrates FROM (key-1) TO key. * - migrations[1] = v0 → v1 (synthetic demo per CONTEXT D-05). * - migrations[2] = v1 → v2 will be added in Phase 4 when Roothold/prestige state lands. * * v0 was a hypothetical prior shape `{garden: []}`; v1 is the minimal Phase-2 shape per CONTEXT D-04: * garden tiles, plants, harvested fragment IDs, lastTickAt, settings. */ export const migrations: Record = { 1: (s: unknown) => { const v0 = (s ?? {}) as { garden?: unknown[] }; return { garden: { tiles: v0.garden ?? [] }, plants: [], harvestedFragmentIds: [], lastTickAt: Date.now(), settings: { musicVolume: 0.7, ambientVolume: 0.5, sfxVolume: 0.8 }, }; }, }; export function migrate( payload: unknown, fromVersion: number, ): { payload: unknown; toVersion: number } { if (fromVersion < 0) { throw new Error(`Cannot migrate from negative version ${fromVersion}`); } let current = payload; let v = fromVersion; while (v < CURRENT_SCHEMA_VERSION) { const next = v + 1; const fn = migrations[next]; if (!fn) { throw new Error(`No migration registered for v${v} → v${next}`); } current = fn(current); v = next; } if (v > CURRENT_SCHEMA_VERSION) { throw new Error(`Cannot migrate from future version ${fromVersion} (current: ${CURRENT_SCHEMA_VERSION})`); } return { payload: current, toVersion: v }; } ``` Note: the future-version throw protects against `migrate(p, 99)` per RESEARCH Pitfall 7 assertion #3. **Step 6 — `src/save/migrations.test.ts`** with all 6 behaviors above. Particularly: ```typescript it('synthetic v0 payload migrates to v1 shape (CONTEXT D-04 + D-05)', () => { const v0 = { garden: [{ id: 'tile-1' }, { id: 'tile-2' }] }; const result = migrate(v0, 0); expect(result.toVersion).toBe(1); expect(result.payload).toMatchObject({ garden: { tiles: [{ id: 'tile-1' }, { id: 'tile-2' }] }, plants: [], harvestedFragmentIds: [], lastTickAt: expect.any(Number), settings: { musicVolume: expect.any(Number), ambientVolume: expect.any(Number), sfxVolume: expect.any(Number) }, }); }); ``` **Step 7 — Run `npm test` and confirm all 16 tests in this task pass (5+5+6).** **Step 8 — Commit `feat(01-03): save envelope + canonical-JSON CRC32 + synthetic v0→v1 migration`.** npx vitest run src/save/checksum.test.ts src/save/envelope.test.ts src/save/migrations.test.ts - All 6 files exist: `for f in checksum envelope migrations; do test -f src/save/$f.ts && test -f src/save/$f.test.ts; done`. - `src/save/checksum.ts` exports both `crc32hex` and `canonicalJSON` — verify with `grep -E "^export function (crc32hex|canonicalJSON)" src/save/checksum.ts | wc -l` returns 2. - `src/save/envelope.ts` exports `wrap`, `unwrap`, `SaveCorruptError`, `SaveEnvelopeSchema` — verify with `grep -cE "^export (function|class|const) (wrap|unwrap|SaveCorruptError|SaveEnvelopeSchema)" src/save/envelope.ts` returns 4. - `src/save/migrations.ts` exports `migrate`, `CURRENT_SCHEMA_VERSION`, `migrations` — verify with `grep -cE "^export (function|const) (migrate|CURRENT_SCHEMA_VERSION|migrations)" src/save/migrations.ts` returns 3. - `migrations.ts` v0→v1 migration produces the v1 shape from CONTEXT D-04 — verify with `grep -E "tiles:|plants:|harvestedFragmentIds:|lastTickAt:|settings:" src/save/migrations.ts | grep -v '^#' | wc -l` returns at least 5. - `npm test` for these 3 files passes 16 tests total — verify with `npx vitest run src/save/checksum.test.ts src/save/envelope.test.ts src/save/migrations.test.ts 2>&1 | grep -E "16 passed|Tests *16"`. - No `any` types in production code (excluding test files) — verify with `grep -nE ': any\\b' src/save/checksum.ts src/save/envelope.ts src/save/migrations.ts`; expect zero matches (CLAUDE.md TypeScript-strict rule). Pure-function save core (checksum, envelope, migrations) implemented per RESEARCH Patterns 1 + 2; 16 Vitest tests covering all RESEARCH Pitfall 7 assertions plus canonical-JSON determinism plus checksum-mismatch throw; no `any` in production; commit landed. Task 2: idb DB + localStorage fallback adapter (CORE-04) + snapshots (last-3 retention) + persist API src/save/db.ts, src/save/db-localstorage-adapter.ts, src/save/db.test.ts, src/save/snapshots.ts, src/save/snapshots.test.ts, src/save/persist.ts, src/save/persist.test.ts - c:/Users/josh1/Documents/Code/TheLastGarden/package.json (Plan 01 Task 1 already installed `fake-indexeddb@^6` as a devDependency — confirm `grep -q '"fake-indexeddb"' package.json` exits 0 before writing the IDB tests) - .planning/phases/01-foundations-and-doctrine/01-RESEARCH.md § "Pattern 3: Last-3 Pre-Migration Snapshots" (verbatim code), § "Code Examples — Persist API call with respectful surfacing" (verbatim code) - .planning/phases/01-foundations-and-doctrine/01-RESEARCH.md § "Common Pitfalls — Pitfall 2: navigator.storage.persist() returns false on iOS Safari most of the time" - .planning/research/PITFALLS.md #8 (storage eviction; multi-layer write requirement) - REQUIREMENTS.md CORE-04 ("with localStorage fallback") + ROADMAP success criterion #2 (the orchestrator authorized implementing the fallback in Phase 1; this task ships ~30 LoC + 1 test) - src/save/envelope.ts (read the SaveEnvelope type from Task 1 — snapshots.ts and db.ts need it) - idb 8.0.3 README: openDB upgrade callback shape, transaction API - **db.ts:** - Test 1: `openSaveDB()` returns an IDBPDatabase with two object stores: `saves` and `save_snapshots`. - Test 2: `saves` store uses keyPath `'id'` (singleton; only one save per slot). - Test 3: `save_snapshots` store uses keyPath `'id'`. - Test 4: `put` + `get` round-trips a SaveEnvelope without modification. - **Test 5 (CORE-04 fallback): when `openDB` rejects (stub-injected), `openSaveDB()` returns a `LocalStorageDBAdapter` and the same `put`/`get` round-trip succeeds against `localStorage`.** - **db-localstorage-adapter.ts:** - Implements the minimal interface used by the rest of the save layer (`get(store, key)`, `put(store, value)`, `delete(store, key)`, `getAll(store)`, plus a `transaction()` helper that proxies to direct localStorage operations — snapshots.ts uses `db.transaction('save_snapshots', 'readwrite').objectStore(...)`). - Namespaces keys under `tlg.saves.` and `tlg.save_snapshots.`. - JSON-encodes values; throws on missing. - **snapshots.ts:** - Test 1: After 1 `snapshot()` call, `listSnapshots()` returns 1 entry. - Test 2 (the load-bearing one for CORE-08 per Pitfall 7 #5): After 5 successive `snapshot()` calls, `listSnapshots()` returns exactly 3 entries, in newest-first order. - Test 3: Each pruned (deleted) entry has the oldest `savedAt` timestamps. - Test 4: `listSnapshots()` on an empty store returns `[]`. - **persist.ts:** - Test 1: When `navigator.storage.persist` exists and resolves true, returns `{granted: true, apiAvailable: true}`. - Test 2: When `navigator.storage.persist` exists and resolves false, returns `{granted: false, apiAvailable: true}`. - Test 3: When `navigator.storage.persist` throws, returns `{granted: false, apiAvailable: true}`. - Test 4: When `navigator.storage` is missing entirely, returns `{granted: false, apiAvailable: false}`. **Step 1 — `src/save/db-localstorage-adapter.ts`** (~30-40 LoC). The adapter implements the minimal interface that `snapshots.ts` and Phase 2's save consumer will call. Use Write tool: ```typescript import type { SaveEnvelope } from './envelope'; /** * CORE-04 fallback path. When IndexedDB is unavailable (private mode, blocked * by browser, quota exceeded), `openSaveDB()` returns this adapter instead of * an IDBPDatabase. The interface intersects with what snapshots.ts and Phase 2's * save consumer actually call — get/put/delete on the two stores (`saves`, * `save_snapshots`) plus a transaction helper that, for localStorage, is a * straight-through proxy (no real transaction semantics — single-threaded * synchronous storage). * * Per .planning/research/PITFALLS.md #8, multi-layer storage is the v1 contract; * IndexedDB is primary, localStorage is the fallback when IDB throws. */ type StoreName = 'saves' | 'save_snapshots'; interface SavedRecord { id: string; envelope: SaveEnvelope; savedAt: string; } interface SnapshotRecord { id: string; schemaVersion: number; savedAt: string; envelope: SaveEnvelope; } type RecordOf = S extends 'saves' ? SavedRecord : SnapshotRecord; function nsKey(store: StoreName, id: string): string { return `tlg.${store}.${id}`; } function nsPrefix(store: StoreName): string { return `tlg.${store}.`; } export class LocalStorageDBAdapter { readonly objectStoreNames = { contains: (s: string) => s === 'saves' || s === 'save_snapshots', }; async get(store: S, key: string): Promise | undefined> { const raw = localStorage.getItem(nsKey(store, key)); return raw ? (JSON.parse(raw) as RecordOf) : undefined; } async put(store: S, value: RecordOf): Promise { localStorage.setItem(nsKey(store, value.id), JSON.stringify(value)); } async delete(store: StoreName, key: string): Promise { localStorage.removeItem(nsKey(store, key)); } async getAll(store: S): Promise[]> { const prefix = nsPrefix(store); const out: RecordOf[] = []; for (let i = 0; i < localStorage.length; i++) { const k = localStorage.key(i); if (k && k.startsWith(prefix)) { const raw = localStorage.getItem(k); if (raw) out.push(JSON.parse(raw) as RecordOf); } } return out; } /** * Transaction shim — localStorage has no real transactions. We expose the * same shape as idb's transaction() so snapshots.ts's `db.transaction(...).objectStore(...)` * pattern works against both backends. `done` resolves immediately because * each set/remove is its own atomic operation. */ transaction(store: StoreName, _mode: 'readwrite' | 'readonly') { const adapter = this; return { objectStore: (s: StoreName) => ({ put: (value: RecordOf) => adapter.put(s, value), get: (key: string) => adapter.get(s, key), delete: (key: string) => adapter.delete(s, key), getAll: () => adapter.getAll(s), }), done: Promise.resolve(), }; } } ``` **Step 2 — `src/save/db.ts`:** ```typescript import { openDB, type IDBPDatabase } from 'idb'; import type { SaveEnvelope } from './envelope'; import { LocalStorageDBAdapter } from './db-localstorage-adapter'; export const SAVE_DB_NAME = 'tlg-save'; const DB_VERSION = 1; export interface SavedRecord { id: 'main'; // singleton key — Phase 1 ships one save slot only envelope: SaveEnvelope; savedAt: string; // ISO8601 } export interface SnapshotRecord { id: string; // `${schemaVersion}-${savedAt}` schemaVersion: number; savedAt: string; envelope: SaveEnvelope; } export interface SaveDBSchema { saves: { key: string; value: SavedRecord }; save_snapshots: { key: string; value: SnapshotRecord }; } /** * 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`). */ export type SaveDB = IDBPDatabase | LocalStorageDBAdapter; /** * Opens the save DB. Tries IndexedDB first; on rejection (private mode, blocked, * quota exceeded — anything that makes openDB throw), falls back to a * LocalStorageDBAdapter that exposes the same minimal interface. * * CORE-04: "IndexedDB-primary with localStorage fallback". * Tested in db.test.ts via stub-injected openDB rejection. */ export async function openSaveDB(): Promise { try { return await openDB(SAVE_DB_NAME, DB_VERSION, { upgrade(db) { if (!db.objectStoreNames.contains('saves')) { db.createObjectStore('saves', { keyPath: 'id' }); } if (!db.objectStoreNames.contains('save_snapshots')) { db.createObjectStore('save_snapshots', { keyPath: 'id' }); } }, }); } catch (err) { // IDB unavailable — fall back to localStorage. Phase 2's settings UI // will surface a "running on localStorage" notice when this path triggers // (per .planning/research/PITFALLS.md #8 multi-layer write requirement). console.warn('[save] IndexedDB unavailable, falling back to localStorage:', err); return new LocalStorageDBAdapter(); } } ``` Per RESEARCH § "Don't Hand-Roll", `idb` is the right wrapper. The two-store split (`saves` + `save_snapshots`) is per RESEARCH Pattern 3 — snapshots are kept separate so migrating the main save never affects the snapshot history. The localStorage fallback adapter mirrors the same two stores, namespaced under `tlg.saves.*` / `tlg.save_snapshots.*`. **Step 3 — `src/save/db.test.ts`:** Plan 01 Task 1 already installed `fake-indexeddb@^6` (verify with `grep -q '"fake-indexeddb"' package.json` before authoring); import it directly: ```typescript import { describe, it, expect, beforeEach, vi } from 'vitest'; import 'fake-indexeddb/auto'; // happy-dom doesn't ship IDB; fake-indexeddb is the polyfill (installed by Plan 01) import { openSaveDB, SAVE_DB_NAME } from './db'; import { wrap } from './envelope'; import { LocalStorageDBAdapter } from './db-localstorage-adapter'; beforeEach(async () => { // Reset IDB and localStorage between tests indexedDB.deleteDatabase(SAVE_DB_NAME); localStorage.clear(); vi.unstubAllGlobals(); vi.resetModules(); }); describe('openSaveDB (CORE-04 IndexedDB-primary path)', () => { it('opens a DB with saves and save_snapshots object stores', async () => { const db = await openSaveDB(); expect(db.objectStoreNames.contains('saves')).toBe(true); expect(db.objectStoreNames.contains('save_snapshots')).toBe(true); }); it('round-trips a SaveEnvelope through saves store', async () => { const db = await openSaveDB(); const envelope = wrap({ hello: 'world' }, 1); await db.put('saves', { id: 'main', envelope, savedAt: new Date().toISOString() }); const retrieved = await db.get('saves', 'main'); expect(retrieved?.envelope).toEqual(envelope); }); }); describe('openSaveDB (CORE-04 localStorage fallback path)', () => { it('falls back to LocalStorageDBAdapter when IndexedDB is unavailable', async () => { // Stub the idb module's openDB so it rejects, simulating private mode / blocked IDB. vi.doMock('idb', async () => ({ openDB: vi.fn().mockRejectedValue(new Error('IDB unavailable (test stub)')), })); // Re-import db.ts after the mock is registered so it picks up the rejecting openDB. const { openSaveDB: openSaveDBFresh } = await import('./db'); const db = await openSaveDBFresh(); expect(db).toBeInstanceOf(LocalStorageDBAdapter); // Round-trip works against localStorage const envelope = wrap({ fallback: true }, 1); await db.put('saves', { id: 'main', envelope, savedAt: new Date().toISOString() }); const retrieved = await db.get('saves', 'main'); expect(retrieved?.envelope).toEqual(envelope); // Verify it actually wrote to localStorage (not just memory) expect(localStorage.getItem('tlg.saves.main')).toBeTruthy(); vi.doUnmock('idb'); }); }); ``` **Step 4 — `src/save/snapshots.ts`** (per RESEARCH Pattern 3 verbatim, but consuming the union `SaveDB` type so it works against both backends): ```typescript import { openSaveDB } from './db'; import type { SaveEnvelope } from './envelope'; export interface SnapshotEntry { id: string; schemaVersion: number; savedAt: string; envelope: SaveEnvelope; } const RETAIN = 3; /** * Write a pre-migration snapshot. After every write, prune to the 3 newest * entries by savedAt (descending). Works against both IDB and localStorage backends. */ export async function snapshot(envelope: SaveEnvelope): Promise { const db = await openSaveDB(); const tx = db.transaction('save_snapshots', 'readwrite'); const store = tx.objectStore('save_snapshots'); const savedAt = new Date().toISOString(); // Make ID unique even if two snapshots fire in the same ms (rare in tests) const id = `${envelope.schemaVersion}-${savedAt}-${Math.random().toString(36).slice(2, 8)}`; await store.put({ id, schemaVersion: envelope.schemaVersion, savedAt, envelope }); // Prune const all = await store.getAll(); const sorted = all.sort((a, b) => b.savedAt.localeCompare(a.savedAt)); const toDelete = sorted.slice(RETAIN); await Promise.all(toDelete.map((e) => store.delete(e.id))); await tx.done; } export async function listSnapshots(): Promise { const db = await openSaveDB(); const all = await db.getAll('save_snapshots'); return all.sort((a, b) => b.savedAt.localeCompare(a.savedAt)); } ``` **Step 5 — `src/save/snapshots.test.ts`** with the 4 behaviors above. The CORE-08 test: ```typescript import { describe, it, expect, beforeEach } from 'vitest'; import 'fake-indexeddb/auto'; import { snapshot, listSnapshots } from './snapshots'; import { wrap } from './envelope'; import { SAVE_DB_NAME } from './db'; beforeEach(() => indexedDB.deleteDatabase(SAVE_DB_NAME)); describe('CORE-08: last-3 snapshot retention', () => { it('retains exactly 3 newest entries after 5 successive snapshot calls', async () => { for (let i = 0; i < 5; i++) { await snapshot(wrap({ generation: i }, 1)); await new Promise((r) => setTimeout(r, 2)); // ensure savedAt timestamps differ } const list = await listSnapshots(); expect(list).toHaveLength(3); // Newest first: payloads should be {generation:4}, {generation:3}, {generation:2} expect(list.map((e) => (e.envelope.payload as any).generation)).toEqual([4, 3, 2]); }); }); ``` **Step 6 — `src/save/persist.ts`** (per RESEARCH § "Code Examples — Persist API call with respectful surfacing" verbatim): ```typescript export interface PersistResult { granted: boolean; apiAvailable: boolean; } /** * Request persistent storage from the browser. Returns granted=true only if * the browser actually granted persistence (Chrome/Firefox/Edge mostly will; * iOS Safari mostly will NOT — see .planning/phases/01-foundations-and-doctrine/01-RESEARCH.md * Pitfall 2 + .planning/research/PITFALLS.md #8). Caller (Phase 2 settings UI) * surfaces apiAvailable=false / granted=false respectfully. */ export async function requestPersistence(): Promise { if (typeof navigator === 'undefined' || !('storage' in navigator) || !navigator.storage || !('persist' in navigator.storage)) { return { granted: false, apiAvailable: false }; } try { const granted = await navigator.storage.persist(); return { granted, apiAvailable: true }; } catch { return { granted: false, apiAvailable: true }; } } ``` **Step 7 — `src/save/persist.test.ts`** with the 4 behaviors. Use Vitest's `vi.stubGlobal` to mock `navigator.storage.persist` per case: ```typescript import { describe, it, expect, vi, beforeEach } from 'vitest'; import { requestPersistence } from './persist'; describe('requestPersistence (CORE-05)', () => { beforeEach(() => vi.unstubAllGlobals()); it('returns granted=true when navigator.storage.persist resolves true', async () => { vi.stubGlobal('navigator', { storage: { persist: async () => true } }); expect(await requestPersistence()).toEqual({ granted: true, apiAvailable: true }); }); it('returns granted=false when navigator.storage.persist resolves false', async () => { vi.stubGlobal('navigator', { storage: { persist: async () => false } }); expect(await requestPersistence()).toEqual({ granted: false, apiAvailable: true }); }); it('returns granted=false when persist throws', async () => { vi.stubGlobal('navigator', { storage: { persist: async () => { throw new Error('boom'); } } }); expect(await requestPersistence()).toEqual({ granted: false, apiAvailable: true }); }); it('returns apiAvailable=false when navigator.storage is missing', async () => { vi.stubGlobal('navigator', {}); expect(await requestPersistence()).toEqual({ granted: false, apiAvailable: false }); }); }); ``` **Step 8 — Verify all tests pass: `npx vitest run src/save/db.test.ts src/save/snapshots.test.ts src/save/persist.test.ts`.** **Step 9 — Commit `feat(01-03): idb DB + localStorage fallback adapter (CORE-04) + last-3 snapshot retention + navigator.storage.persist API`.** npx vitest run src/save/db.test.ts src/save/snapshots.test.ts src/save/persist.test.ts - Precondition: `fake-indexeddb` is listed in devDependencies (installed by Plan 01 Task 1) — verify with `grep -q '"fake-indexeddb"' package.json` (this is a precondition assertion, not a new install). - `src/save/db-localstorage-adapter.ts` exists and exports `LocalStorageDBAdapter` — verify with `grep -q "^export class LocalStorageDBAdapter" src/save/db-localstorage-adapter.ts`. - `LocalStorageDBAdapter` namespaces under `tlg.saves.*` and `tlg.save_snapshots.*` — verify with `grep -E "tlg\\.(saves|save_snapshots)\\." src/save/db-localstorage-adapter.ts | wc -l` returns at least 2. - `src/save/db.ts` opens a DB with stores `saves` AND `save_snapshots` — verify with `grep -E "createObjectStore\\('(saves|save_snapshots)'" src/save/db.ts | wc -l` returns 2. - `src/save/db.ts` wraps `openDB` in try/catch and returns `LocalStorageDBAdapter` on failure (CORE-04 fallback) — verify with `grep -q "LocalStorageDBAdapter" src/save/db.ts && grep -E "catch\\b" src/save/db.ts`. - `src/save/db.test.ts` includes a test that stub-injects an IDB failure and verifies the fallback path round-trips — verify with `grep -q "vi.doMock" src/save/db.test.ts && grep -q "LocalStorageDBAdapter" src/save/db.test.ts && grep -q "tlg.saves.main" src/save/db.test.ts`. - `src/save/snapshots.ts` exports `snapshot` and `listSnapshots` — verify with `grep -cE "^export (async )?function (snapshot|listSnapshots)" src/save/snapshots.ts` returns 2. - `RETAIN` constant in snapshots.ts is exactly 3 — verify with `grep -E "RETAIN\\s*=\\s*3" src/save/snapshots.ts`. - `src/save/persist.ts` exports `requestPersistence` and `PersistResult` — verify with `grep -cE "^export (function|interface|type) (requestPersistence|PersistResult)" src/save/persist.ts` returns 2. - The CORE-08 test asserts `toHaveLength(3)` after 5 writes — verify with `grep -q "toHaveLength(3)" src/save/snapshots.test.ts`. - All 4 persist.test.ts cases stub `navigator.storage` — verify with `grep -cE "vi.stubGlobal" src/save/persist.test.ts` returns at least 4. - All tests pass: `npx vitest run src/save/db.test.ts src/save/snapshots.test.ts src/save/persist.test.ts 2>&1 | grep -E "passed"`. - No `any` types in production files (test files may use `as any` for stub typing only) — verify with `grep -nE ': any\\b' src/save/db.ts src/save/db-localstorage-adapter.ts src/save/snapshots.ts src/save/persist.ts`; zero matches. `idb`-wrapped IndexedDB with both stores; `LocalStorageDBAdapter` (~30-40 LoC) implementing the same minimal interface for the CORE-04 fallback path; `openSaveDB()` returns the IDB DB on success and the adapter on rejection; one Vitest test stub-injects an IDB failure and exercises the localStorage round-trip end-to-end (verifies `tlg.saves.main` key is written); last-3 snapshot retention with the CORE-08 5-then-3 invariant test; persist API with all 4 navigator.storage scenarios covered; commit landed. Task 3: Base64 codec + round-trip integration test + index re-exports src/save/codec.ts, src/save/round-trip.test.ts, src/save/index.ts - .planning/phases/01-foundations-and-doctrine/01-RESEARCH.md § "Code Examples — Save round-trip (Phase 1's load-bearing test)" (verbatim test) and § "Common Pitfalls — Pitfall 5: Synchronous lz-string compression of huge saves blocks the main thread" - .planning/phases/01-foundations-and-doctrine/01-RESEARCH.md § "Security Domain" (Phase 1 threat: malformed Base64 import — DoS via huge inflated string; cap payload size at 50MB before decompression) - src/save/envelope.ts (this plan's Task 1 — wrap/unwrap signatures) - src/save/migrations.ts (this plan's Task 1 — migrate signature) - lz-string 1.5.0 README: compressToBase64 / decompressFromBase64 semantics - **codec.ts:** - Test 1: `exportToBase64(env)` returns a non-empty string. - Test 2: `importFromBase64(exportToBase64(env))` deep-equals the original envelope. - Test 3: `importFromBase64('not-valid-base64-junk')` throws (malformed import detection). - Test 4: `importFromBase64()` throws BEFORE decompression (DoS cap). - **round-trip.test.ts (the load-bearing CORE-09 test):** - The full pipeline per RESEARCH § "Save round-trip" verbatim: synthesize a v0 envelope, export to Base64, simulate a "fresh browser" by importing back from Base64, migrate v0 → v1, wrap in a v1 envelope with valid checksum, unwrap, assert original payload returned. - Bonus: write the migrated v1 envelope to IDB, read it back, unwrap, assert equality (proves IDB + envelope + migration + codec all integrate). - **index.ts:** - Re-exports the public surface for Phase 2 consumption. **Step 1 — `src/save/codec.ts`:** ```typescript import LZString from 'lz-string'; import { SaveEnvelopeSchema, type SaveEnvelope } from './envelope'; /** * 50MB cap on Base64 import string length, per Phase 1 threat model * (.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; * still, refuse pathologically large inputs at the boundary. */ export const MAX_IMPORT_BYTES = 50 * 1024 * 1024; /** * Export a SaveEnvelope to a Base64 text blob suitable for "Settings → Export". * Phase 1 ships the function pair; Phase 2 wires the UI button (CORE-09). */ export function exportToBase64(envelope: SaveEnvelope): string { return LZString.compressToBase64(JSON.stringify(envelope)); } /** * Import from a Base64 text blob. Throws on: * - input larger than MAX_IMPORT_BYTES (DoS cap) * - lz-string decompression failure * - JSON parse failure * - SaveEnvelopeSchema validation failure (malformed envelope shape) * * Note: this does NOT verify checksum or run migrations — the caller chains * importFromBase64 → migrate → unwrap. See round-trip.test.ts for the full pipeline. */ 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)'); } const parsed = JSON.parse(decompressed); const validated = SaveEnvelopeSchema.safeParse(parsed); if (!validated.success) { throw new Error(`Imported envelope failed schema validation: ${validated.error.message}`); } return validated.data as SaveEnvelope; } ``` Per RESEARCH Pitfall 5: lz-string is synchronous; for Phase 1 saves (<10KB) this is fine. Document the eventual mitigation as a code comment so Phase 8 perf work knows where to look. Do NOT build the Web Worker now (premature per CONTEXT D-09 minimum-viable directive). **Step 2 — `src/save/round-trip.test.ts`** (per RESEARCH § "Code Examples — Save round-trip" verbatim, extended with IDB integration): ```typescript import { describe, it, expect, beforeEach } from 'vitest'; import 'fake-indexeddb/auto'; import { wrap, unwrap } from './envelope'; import { migrate, CURRENT_SCHEMA_VERSION } from './migrations'; import { exportToBase64, importFromBase64 } from './codec'; import { openSaveDB, SAVE_DB_NAME } from './db'; beforeEach(() => indexedDB.deleteDatabase(SAVE_DB_NAME)); describe('CORE-09 + CORE-04 + CORE-06 + CORE-07: full save round-trip', () => { it('synthetic v0 envelope migrates, round-trips through Base64, validates, persists', async () => { // Pretend a player had an old v0 save lying around (CONTEXT D-05 synthetic v0). const v0Payload = { garden: [{ id: 'tile-1' }, { id: 'tile-2' }] }; // v0 envelope: schemaVersion 0, with a placeholder checksum that we won't verify // (the v0 era didn't have our checksum scheme). const v0Envelope = { schemaVersion: 0, payload: v0Payload, checksum: '00000000', // 8-char hex placeholder }; // Export through Base64 codec const exported = exportToBase64(v0Envelope as any); expect(exported.length).toBeGreaterThan(0); // Import (simulating a fresh browser) — note: import returns a parsed envelope // that PASSES our SaveEnvelopeSchema (schemaVersion 0 is allowed since z.number().nonnegative()). const imported = importFromBase64(exported); expect(imported.schemaVersion).toBe(0); // Migrate the imported payload const { payload, toVersion } = migrate(imported.payload, imported.schemaVersion); expect(toVersion).toBe(CURRENT_SCHEMA_VERSION); expect(payload).toMatchObject({ garden: { tiles: [{ id: 'tile-1' }, { id: 'tile-2' }] }, plants: [], harvestedFragmentIds: [], lastTickAt: expect.any(Number), settings: { musicVolume: expect.any(Number), ambientVolume: expect.any(Number), sfxVolume: expect.any(Number) }, }); // Re-wrap with current version and a valid checksum const v1Envelope = wrap(payload, toVersion); expect(unwrap(v1Envelope)).toEqual(payload); // Persist to IDB and read back (CORE-04) const db = await openSaveDB(); await db.put('saves', { id: 'main', envelope: v1Envelope, savedAt: new Date().toISOString() }); const retrieved = await db.get('saves', 'main'); expect(retrieved?.envelope).toEqual(v1Envelope); expect(unwrap(retrieved!.envelope)).toEqual(payload); }); it('rejects oversized Base64 import (DoS cap)', () => { const huge = 'A'.repeat(50 * 1024 * 1024 + 1); expect(() => importFromBase64(huge)).toThrow(/exceeds/); }); it('rejects malformed Base64', () => { expect(() => importFromBase64('not-valid-base64-)(*&^%$')).toThrow(); }); }); ``` **Step 3 — `src/save/index.ts`** — public re-exports for Phase 2: ```typescript /** * Public surface of the save layer. Phase 2's tick scheduler + Zustand store * are the first consumers. */ export { wrap, unwrap, SaveCorruptError, SaveEnvelopeSchema } from './envelope'; export type { SaveEnvelope } from './envelope'; export { migrate, CURRENT_SCHEMA_VERSION, migrations } 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 } from './db'; export { LocalStorageDBAdapter } from './db-localstorage-adapter'; export { crc32hex, canonicalJSON } from './checksum'; ``` **Step 4 — Run the full save test suite: `npx vitest run src/save/`.** Expect all tests across 8 files (checksum, envelope, migrations, db, snapshots, persist, round-trip; LocalStorageDBAdapter is exercised by db.test.ts) to pass. **Step 5 — Run `npm test`** to confirm the entire test suite (sentinel + lint-firewall from Plan 02 + all save tests) passes. **Step 6 — Run `npm run build`** to confirm the save layer compiles cleanly with TypeScript strict. **Step 7 — Commit `feat(01-03): Base64 codec + DoS-capped import + full save round-trip integration test + index re-exports`.** npx vitest run src/save/ && npm run build - `src/save/codec.ts` exports `exportToBase64`, `importFromBase64`, `MAX_IMPORT_BYTES` — verify with `grep -cE "^export (function|const) (exportToBase64|importFromBase64|MAX_IMPORT_BYTES)" src/save/codec.ts` returns 3. - `MAX_IMPORT_BYTES` is exactly 50MB — verify with `grep -E "MAX_IMPORT_BYTES\\s*=\\s*50\\s*\\*\\s*1024\\s*\\*\\s*1024" src/save/codec.ts`. - `importFromBase64` validates against `SaveEnvelopeSchema` — verify with `grep -q "SaveEnvelopeSchema.safeParse" src/save/codec.ts`. - `src/save/index.ts` exports the full public surface including `LocalStorageDBAdapter` — verify with `grep -cE "^export " src/save/index.ts` returns at least 11 (wrap, unwrap, SaveCorruptError, migrate, snapshot, requestPersistence, exportToBase64, importFromBase64, openSaveDB, LocalStorageDBAdapter, crc32hex, etc). - The round-trip test asserts on the v0→v1 migration shape from CONTEXT D-04 — verify with `grep -E "tiles:|plants:|harvestedFragmentIds:|lastTickAt:|settings:" src/save/round-trip.test.ts | grep -v '^#' | wc -l` returns at least 5. - The round-trip test exercises EXPORT → IMPORT → MIGRATE → WRAP → UNWRAP → IDB PUT → IDB GET — verify with `grep -E "exportToBase64|importFromBase64|migrate|wrap|unwrap|openSaveDB|db\\.put|db\\.get" src/save/round-trip.test.ts | wc -l` returns at least 7 (one per stage). - The DoS cap is tested — verify with `grep -q "50 \\* 1024 \\* 1024 + 1" src/save/round-trip.test.ts`. - `npx vitest run src/save/` passes ALL tests — verify exit 0; expect roughly 16+5+4+2+3 = ~30 tests across 7 test files (db.test.ts now has 1 extra fallback test). - `npm run build` exits 0 — TypeScript strict compilation passes including the full save layer. - No `any` types in production code (codec.ts, index.ts) — verify with `grep -nE ': any\\b' src/save/codec.ts src/save/index.ts`; zero matches. Base64 export/import codec with 50MB DoS cap; full round-trip test exercising every save layer file (CORE-04 + 06 + 07 + 09 in one go); public re-exports (including `LocalStorageDBAdapter` and `SaveDB` union type) indexed for Phase 2; entire save test suite green under `npm test`; `npm run build` succeeds under TypeScript strict; commit landed. ## Trust Boundaries | Boundary | Description | |----------|-------------| | Disk → IndexedDB | Saved envelopes loaded back from storage; data may have been corrupted in lossy storage (Chrome eviction, partial write, browser crash). Mitigated by CRC-32 envelope checksum. | | User → Base64 import | A pasted Base64 string from "Settings → Import" (Phase 2 wires the UI; Phase 1 ships the function). User is single-player but the input is still untrusted bytes. | ## STRIDE Threat Register | Threat ID | Category | Component | Disposition | Mitigation Plan | |-----------|----------|-----------|-------------|-----------------| | T-01-01 | Tampering | `unwrap()` of save envelope | mitigate | CRC-32 checksum over canonical JSON detects corruption (lossy storage, partial writes); throws `SaveCorruptError` on mismatch. **Not** a cryptographic guarantee — a player editing their own save is by-design acceptable in a single-player contemplative game per RESEARCH § Security Domain. Phase 2's UI surfaces the recovery option (last-3 snapshots from CORE-08) when this throws. | | T-01-02 | Denial of Service | `importFromBase64()` | mitigate | Cap input length at `MAX_IMPORT_BYTES = 50 * 1024 * 1024` BEFORE invoking `lz-string.decompressFromBase64` (which is synchronous and would block the main thread on huge inputs per RESEARCH Pitfall 5 + § Security Domain). Throws an Error with `/exceeds/` message when input exceeds cap. Tested in round-trip.test.ts. | | T-01-03 | Tampering | Save authentication (player edits Base64 export and reimports) | accept | Single-player game; no leaderboards, no monetization gates in Phase 1; player tampering with their own save is the player's prerogative. Documented explicitly in `codec.ts` and RESEARCH § "Phase 1 explicit security non-goals". CRC-32 detects corruption, NOT adversarial editing — by design. | | T-01-04 | Information Disclosure | Save contents | accept | Phase 1 saves contain no PII (no Keeper name per STRY-07; no auth, no sessions). Garden state, plant data, harvested fragment IDs are non-sensitive. | | T-01-05 | Spoofing | Cross-origin import via URL params (future risk if save-via-link is added) | accept (out of scope) | Phase 1 has no URL import mechanism. Flagged here for Phase 4+ when Settings UI is wired: import flow MUST require explicit user confirmation, NEVER auto-load from URL. | - `npx vitest run src/save/` passes every test (target: ≥30 tests across 7 test files; db.test.ts now includes the CORE-04 localStorage fallback test). - `npm run build` exits 0 under TypeScript strict (no `any` in production code). - `npm run lint` exits 0 (the save layer respects the firewall — no `import` from `src/render/` or `src/ui/`; this would also fire the Plan 02 boundary rule). - All 6 CORE requirements (CORE-04 through CORE-09) have at least one Vitest assertion explicitly named or commented as covering them. CORE-04 specifically covers BOTH the IDB-primary path AND the localStorage-fallback path. - The CRC-32 envelope and the 50MB DoS cap satisfy Phase 1's two STRIDE-mitigate threats. - Save envelope `{schemaVersion, payload, checksum}` with CRC-32 over canonical JSON, exported via wrap/unwrap and tested for round-trip + tamper-detection. - Migration chain with CURRENT_SCHEMA_VERSION = 1 and one synthetic v0 → v1 demo migration that produces the v1 shape from CONTEXT D-04. - IDB DB with two object stores: `saves` (singleton) + `save_snapshots` (last-3 retention). - `LocalStorageDBAdapter` implementing the same minimal interface as the IDB DB; `openSaveDB()` falls back to the adapter when IDB is unavailable (CORE-04 IndexedDB-primary + localStorage-fallback contract). - `requestPersistence()` covers all four navigator.storage scenarios. - Base64 export/import via lz-string with a 50MB DoS cap. - Full round-trip test covers every component end-to-end. - All 6 Phase-1 CORE save requirements automated and green. After completion, create `.planning/phases/01-foundations-and-doctrine/01-03-SUMMARY.md` documenting: - Final test count (`npx vitest run src/save/ 2>&1 | tail -5`). - The exact `CURRENT_SCHEMA_VERSION` (must be 1) and what the v1 shape contains (so Phase 4's `migrate_v1_to_v2` author has the contract). - **CORE-04 fallback note:** the localStorage fallback is shipped in Phase 1 per the orchestrator's revision-iteration-1 decision (REQUIREMENTS.md CORE-04 + ROADMAP success criterion #2 both require it). The fallback is a thin (~30-40 LoC) `LocalStorageDBAdapter` exposing the same minimal interface as the IDB DB; `openSaveDB()` wraps `openDB()` in try/catch and returns the adapter on rejection. A single Vitest test (`db.test.ts` "falls back to LocalStorageDBAdapter when IndexedDB is unavailable") stub-injects an IDB failure via `vi.doMock('idb')` and asserts the round-trip succeeds with `tlg.saves.main` written to localStorage. - Confirmation that the public surface in `src/save/index.ts` is the only entry point Phase 2 should import from.