--- phase: 02-ui-performance reviewed: 2026-07-31T04:09:21Z depth: standard files_reviewed: 26 files_reviewed_list: - neode-ui/src/api/rpc-client.ts - neode-ui/src/components/MeshMap.vue - neode-ui/src/components/RefreshIndicator.vue - neode-ui/src/composables/useCachedResource.ts - neode-ui/src/stores/auth.ts - neode-ui/src/stores/homeStatus.ts - neode-ui/src/stores/mesh.ts - neode-ui/src/stores/resources.ts - neode-ui/src/stores/transport.ts - neode-ui/src/views/AppDetails.vue - neode-ui/src/views/Apps.vue - neode-ui/src/views/Chat.vue - neode-ui/src/views/Cloud.vue - neode-ui/src/views/Dashboard.vue - neode-ui/src/views/Discover.vue - neode-ui/src/views/Home.vue - neode-ui/src/views/Marketplace.vue - neode-ui/src/views/MarketplaceAppDetails.vue - neode-ui/src/views/Mesh.vue - neode-ui/src/views/Server.vue - neode-ui/src/views/server/OpenWrtGateway.vue - neode-ui/src/views/dashboard/DashboardRouterView.vue - neode-ui/src/views/dashboard/dashboardViewWrappers.ts - neode-ui/src/views/dashboard/keepAliveRoutes.ts - neode-ui/src/views/dashboard/useRouteTransitions.ts - neode-ui/src/views/web5/Web5.vue findings: critical: 1 warning: 6 info: 2 total: 9 status: issues_found fixed_at: 2026-07-31T08:08:53Z fix_status: fixed_except_documented fixed: 8 documented_not_fixed: 1 --- # Phase 02: Code Review Report **Reviewed:** 2026-07-31T04:09:21Z **Depth:** standard **Files Reviewed:** 26 source files (diffed against `a75b6709~1`, the commit before phase 02's first commit) + test files skimmed for correctness of what they pin **Status:** issues_found ## Fix Status (2026-07-31T08:08:53Z) All Critical and Warning findings fixed (CR-01, WR-01 through WR-06), one commit per finding. Of the two Info findings, IN-02 (trivial/zero-risk comment) was fixed; IN-01 (a real refactor across 4 files) was left documented, not fixed, per this pass's trivial/zero-risk bar for Info findings. Full test suite (774 tests / 95 files), `vue-tsc --noEmit`, and `npm run build` are all green after every fix. See each finding's own **Status:** line below for the commit hash. | Finding | Status | Commit | |---|---|---| | CR-01 | Fixed | `57989dfc` | | WR-01 | Fixed | `61057704` | | WR-02 | Fixed | `751b05f2` | | WR-03 | Fixed (flagged for human verification — concurrency-race logic) | `69358bf6` | | WR-04 | Fixed | `5f7cd4c8` | | WR-05 | Fixed | `7e4e739e` | | WR-06 | Fixed | `0486045d` | | IN-01 | Documented, not fixed | — | | IN-02 | Fixed | `b5506025` | ## Summary Phase 02 layers a KeepAlive instance cache and a stale-while-revalidate resource composable (`useCachedResource`) onto ~15 views, plus an activate/deactivate lifecycle audit across the main tabs. The architecture itself (`keepAliveRoutes.ts` classifier, `dashboardViewWrappers.ts` memoized wrapper factory, `useCachedResource.ts`'s onActivated hook, the Cloud.vue browse-peer concurrency pool) is sound: no unbounded-growth bugs, no wrapper name collisions, and the concurrency-capped fan-out in Cloud.vue is race-free (cursor increments are synchronous, no double-processing of a peer). The real defects are concentrated in three places the task specifically asked to scrutinize: (1) one cache key genuinely violates T-02-01 (wallet data persisted to sessionStorage by default) in a file this phase directly edited, (2) a cache-key-sharing decision made in 02-04 has a real, previously unrecognized race that silently degrades a UI section, and (3) one onActivated/onDeactivated pair (MeshMap.vue) doesn't fully mirror the "only-while-visible" discipline applied everywhere else in the same rewrite. None of these were already called out in 02-FINDINGS.md's Outstanding section or the task's exclusion list (Server.vue remount gap, timing regressions, UIFIX-01..06), so they're reported fresh below. ## Critical Issues ### CR-01: Web5.vue's wallet balance resources persist to sessionStorage by default (T-02-01 violation) **File:** `neode-ui/src/views/web5/Web5.vue:140-143, 293-300` **Issue:** `profitsRes` (`web5.networking-profits`) and, more importantly, `lndInfoRes` (`web5.lnd-info`, holding `balance_sats`/`channel_balance_sats`/ `synced_to_chain`) are declared with no `persist` field at all: ```ts const lndInfoRes = useCachedResource<{ balance_sats: number channel_balance_sats: number synced_to_chain: boolean }>({ key: 'web5.lnd-info', fetcher: (signal) => rpcClient.call({ method: 'lnd.getinfo', signal, dedup: true, maxRetries: 1 }), }) ``` `useCachedResource`'s default is `persist = opts.persist ?? true` (`useCachedResource.ts:64`), so every successful fetch calls `writeSnapshot('web5.lnd-info', {balance_sats, channel_balance_sats, synced_to_chain}, fetchedAt)`, writing the node's live on-chain and Lightning channel balances into `sessionStorage` as plaintext JSON (`resource:web5.lnd-info`). This is exactly the class of data T-02-01 exists to keep out of sessionStorage, and it directly contradicts the pattern this same phase established everywhere else — every other resource in this phase (`home.wallet-status`, `mesh.self-onion`, `mesh.self-did`, `mesh.contacts`, `server.vpn-peers`, `server.tor-services`, `AppDetails.vue`'s credentials resource, …) makes an *explicit* `persist: false` decision precisely because "never defaulted" was the hard rule (`02-02-SUMMARY.md` key-decisions: `"persist decided explicitly per cache key (never defaulted) per T-02-01"`). This is not a hypothetical: `neode-ui/src/views/Home.vue:527-544`'s own code comment, added by this phase, explicitly documents the gap and declines to close it: *"web5.lnd-info's default persist:true (Web5.vue is out of this plan's file scope to fix) would leak balance data to sessionStorage via its own independent refresh cycle regardless of what Home declares."* Web5.vue **is** in this review's file scope (63 lines changed by this phase, including the onActivated/onDeactivated lifecycle wrapped directly around these two resources), so the fix belongs here now rather than being deferred again. `02-FINDINGS.md`'s `## Outstanding` section does not list this gap. **Fix:** ```ts const profitsRes = useCachedResource({ key: 'web5.networking-profits', fetcher: (signal) => rpcClient.call({ method: 'wallet.networking-profits', signal, dedup: true, maxRetries: 1 }), persist: false, // routing/content-sale profit totals — financial data (T-02-01) }) const lndInfoRes = useCachedResource<{ balance_sats: number channel_balance_sats: number synced_to_chain: boolean }>({ key: 'web5.lnd-info', fetcher: (signal) => rpcClient.call({ method: 'lnd.getinfo', signal, dedup: true, maxRetries: 1 }), persist: false, // wallet balance — must never land in sessionStorage (T-02-01) }) ``` Also update Home.vue's comment once fixed — it currently documents this as a known, deliberately-unfixed gap. **Status:** Fixed in `57989dfc` (`fix(02-review): CR-01 web5.lnd-info/profits resources must not persist to sessionStorage`). ## Warnings ### WR-01: `app-catalog` cache key shared by two non-equivalent fetchers — dedup races silently drop Discover's featured-banner data **File:** `neode-ui/src/views/Marketplace.vue:242-247`, `neode-ui/src/views/Discover.vue:273-291` **Issue:** Both views register a `useCachedResource` against the same key `'app-catalog'`, but with different fetchers: ```ts // Marketplace.vue const catalogResource = useCachedResource({ key: 'app-catalog', fetcher: async () => getCuratedAppList(), // static hardcoded list only ... }) // Discover.vue const catalogResource = useCachedResource({ key: 'app-catalog', fetcher: async () => { const catalog = await fetchAppCatalog() // dynamic registry fetch if (catalog) { catalogFeatured.value = catalog.featured // <- Discover-local side effect return catalog.apps } catalogFeatured.value = null return getCuratedAppList() }, ... }) ``` `resources.ts`'s `refresh()` dedupes by key via an `inflight` map: whichever caller's `refresh()` reaches `store.refresh()` first (synchronously, before the other subscriber's call) sets `inflight`, and every other concurrent caller for the same key just awaits that *same* promise — its own fetcher never runs. Since both views are simultaneously KeepAlive-eligible (`/dashboard/marketplace` and `/dashboard/discover` are both in `KEEP_ALIVE_PATHS`), and both re-subscribe/re-revalidate on every activation and TTL lapse, whichever view's fetcher wins a given race governs the shared cache entry for both. When Marketplace's simpler fetcher wins, Discover's `catalogFeatured.value` side effect is silently skipped for that cycle — `featuredBanner` (`Discover.vue:450`) falls back to the static `FEATURED_DEFINITIONS` entry with no error, no stale indicator, and no way for the user to tell the dynamic catalog's featured banner was dropped. Concretely: visit Marketplace first (within its 300s TTL), then Discover — Discover hydrates the already-populated `entries` Map entry, sees it isn't stale, and never calls its own fetcher at all, so `catalogFeatured` stays at its initial `null` for the rest of that TTL window. 02-04-SUMMARY.md's own rationale ("both are valid producers of the shared 'app-catalog' cache key") is the flawed premise here — the two fetchers are not interchangeable because only one carries the `catalogFeatured` side effect, and `store.refresh()`'s dedup silently privileges whichever one wins. **Fix:** Either (a) give Discover.vue its own cache key (`'app-catalog:discover'`) so its richer fetcher always runs on its own schedule, or (b) move the `catalogFeatured` derivation out of the fetcher and into a `computed`/store-level cache so it doesn't depend on which of the two subscribers' fetcher happened to execute, or (c) make Marketplace.vue call `fetchAppCatalog()` too (unifying the two fetchers) so both producers are genuinely interchangeable as the design comment assumes. **Status:** Fixed in `61057704` (`fix(02-review): WR-01 decouple Discover's featured-banner data from app-catalog dedup race`) — implemented option (b)-adjacent: the featured payload now lives on its own dedicated cache key (`'app-catalog:featured'`) subscribed only by Discover.vue, decoupled entirely from the shared `'app-catalog'` dedup race. The shared key and its dedup behavior are unchanged. ### WR-02: MeshMap.vue's geolocation watch keeps running after the Mesh tab is deactivated **File:** `neode-ui/src/components/MeshMap.vue:69-76, 412-429` **Issue:** `armMapVisibility()`/`disarmMapVisibility()` correctly follow the "only-while-visible" pattern for the resize listener and `ResizeObserver` (added by this phase specifically because MeshMap now survives a tab switch under KeepAlive), but `onDeactivated` does not call `stopSharing()`: ```ts onActivated(() => { if (mapMountFresh) { mapMountFresh = false; return }; armMapVisibility() }) onMounted(() => armMapVisibility()) onDeactivated(() => disarmMapVisibility()) // <- geolocation watch NOT stopped here ``` If the user has "Share Location" enabled (`sharingLocation.value = true`, `geoWatchId` set via `navigator.geolocation.watchPosition`) and then switches away from the Mesh tab to any other main tab, the browser's location watch keeps firing in the background indefinitely — `mesh.updateSelfPosition()` keeps getting called, the browser's location indicator stays active, and GPS polling continues to drain battery — for as long as the session lasts (or until `KEEP_ALIVE_MAX` evicts Mesh.vue's whole subtree and `onUnmounted` finally calls `stopSharing()`). Every other resource this phase added "only-while-visible" handling for in this exact file (resize listener, ResizeObserver) is torn down on deactivate; the geolocation watch — arguably the most expensive/privacy-sensitive of the three — is not. **Fix:** ```ts onDeactivated(() => { disarmMapVisibility() if (sharingLocation.value) stopSharing() }) ``` (If keeping location live across a tab switch is actually desired, that should be a deliberate, documented decision like the other exceptions in this phase — not a gap in an otherwise-systematic "only-while-visible" rewrite.) **Status:** Fixed in `751b05f2` (`fix(02-review): WR-02 stop MeshMap geolocation watch on deactivate, resume on activate`). `onDeactivated` now stops an active watch, tracked via a flag so `onActivated` transparently resumes it on return to the tab (the user's toggle state is preserved, not lost). ### WR-03: OpenWrtGateway.vue's `load(params)` can silently drop a caller's params under concurrent load **File:** `neode-ui/src/views/server/OpenWrtGateway.vue:85-96, 152-166` **Issue:** ```ts let pendingParams: Record | undefined const routerResource = useCachedResource({ key: 'server.openwrt-status', fetcher: (signal) => rpcClient.call({ method: 'openwrt.get-status', params: pendingParams ?? {}, ... }), ... }) async function load(params?: Record) { error.value = '' pendingParams = params await routerResource.refresh() const err = routerResource.error.value ... } ``` `routerResource.refresh()` goes through `resources.ts`'s `store.refresh()`, which dedupes concurrent calls for the same key via its `inflight` map: if a refresh is already in flight (e.g. an auto-revalidation from `useCachedResource`'s own TTL-gated `onActivated`, or the plain `load()` this component's own `onMounted` fires on a stale cache), a second call to `load({host, ssh_user, ssh_password})` (the Connect form's submit handler, `OpenWrtGateway.vue:174`) sets `pendingParams` to the new connect credentials, but `routerResource.refresh()` just returns the *first* call's already-in-flight promise — the fetcher never re-runs, so `params: pendingParams ?? {}` for that in-flight request was already resolved against whatever `pendingParams` held when *that* call started (typically `{}` from a background reconnect). The Connect form's submit `await load({host,...})` then resolves against that unrelated result: the entered host/credentials were never actually sent, and the caller has no way to tell. **Fix:** Give each `load()` call its own request instead of routing through the shared cache's dedup when explicit params are supplied — e.g. bypass `routerResource.refresh()` for the params-carrying path and call `rpcClient.call(...)` directly (then write the result into `routerResource` via `.optimistic()`), or track an explicit "params in flight" flag and reject/ queue overlapping calls with different params instead of silently coalescing them. **Status:** Fixed in `69358bf6` (`fix(02-review): WR-03 never drop OpenWrtGateway Connect form params under concurrent load`). `load(params)` now bypasses `routerResource.refresh()` entirely when explicit params are supplied, calling `rpcClient` directly and writing the result into `routerResource.entry` (rather than via `.optimistic()`, to also set `fetchedAt`/`loadState` consistently with a normal refresh success). This is a real concurrency-race fix — flagged for human verification of the logic (concurrent-load race conditions are inherently hard to prove correct from static reading alone; type-check and the full test suite pass, but there is no dedicated OpenWrtGateway.vue test file to exercise the race directly). ### WR-04: `resources.ts`'s `entry()` silently ignores `persist` after the first call for a key **File:** `neode-ui/src/stores/resources.ts:68-81` **Issue:** ```ts function entry(key: string, persist = true): ResourceEntry { let e = entries.get(key) if (!e) { const snap = persist ? readSnapshot(key) : null e = reactive({ ... }) entries.set(key, e) } return e as ResourceEntry } ``` Only the *first* caller for a given key's `persist` argument has any effect; every subsequent call (from `entry()` itself, or transitively from `optimistic()`, which calls `entry(key)` with no `persist` arg at all — defaulting to `true`) silently reuses whatever was decided the first time. Every current call site happens to be safe because `useCachedResource()` always creates the entry (with the correct, explicit `persist`) before any UI code can call `.optimistic()` on it — but this is a fragile invariant, not an enforced one, and it is exactly the kind of interaction T-02-01 asks this phase to get right. A future resource that calls `store.optimistic(key, ...)` before any `useCachedResource({key, persist: false, ...})` has run in the same tick (e.g. from a Pinia store action fired at app-init, before any component mounts) would silently get `persist: true` and start writing to sessionStorage with no indication anything is wrong. **Fix:** Make `persist` a property of the entry that's set once and asserted consistent, or have `optimistic()` require an explicit `persist` argument (no default) so silent fallback-to-`true` can't happen by omission. **Status:** Fixed in `5f7cd4c8` (`fix(02-review): WR-04 require explicit persist on resources.ts entry()/optimistic()`) — implemented both suggested fixes together: `persist` is now a required (no-default) argument on both `entry()` and `optimistic()`, and the per-key decision is recorded and asserted (dev-only warning) against any later call that disagrees. `useCachedResource`'s `optimistic()` wrapper threads its own already-resolved `persist` value through automatically. The two direct external call sites (Cloud.vue/PeerFiles.vue's per-peer browse cache) and the resources store's unit tests were updated to pass `persist` explicitly, preserving existing behavior exactly. ### WR-05: `server.network-summary`'s abort-on-unmount contract is only half-honored **File:** `neode-ui/src/views/Server.vue:475-482` **Issue:** `networkRes`'s fetcher batches four RPCs, but only two forward the `signal` `useCachedResource` provides for abort-on-unmount: ```ts fetcher: async (signal) => { const [diagRes, fwdRes, vpnRes, dnsRes] = await Promise.allSettled([ rpcClient.call<...>({ method: 'network.diagnostics', signal, ... }), rpcClient.call<...>({ method: 'router.list-forwards', signal, ... }), rpcClient.vpnStatus(), // <- no signal parameter exists on this method rpcClient.dnsStatus(), // <- no signal parameter exists on this method ]) ... } ``` `rpcClient.vpnStatus()`/`dnsStatus()` are convenience wrappers with no `signal` parameter at all, so `aborter.abort()` (fired from `useCachedResource`'s `onScopeDispose`) cannot cancel these two calls. This partially defeats the documented "Abort-on-unmount: the fetcher receives an AbortSignal that fires when the last subscribed component unmounts" contract in `useCachedResource.ts`'s own header comment, for this one resource. **Fix:** Add an optional `signal` parameter to `rpcClient.vpnStatus()`/ `dnsStatus()` (mirroring the pattern already used everywhere else in `rpc-client.ts`) and forward it here. **Status:** Fixed in `7e4e739e` (`fix(02-review): WR-05 forward abort signal through vpnStatus()/dnsStatus()`). ### WR-06: MeshMap.vue re-arms a redundant 300ms fallback timer on every reactivation **File:** `neode-ui/src/components/MeshMap.vue:399-401` **Issue:** `armMapVisibility()` unconditionally calls `setTimeout(initMap, 300)` every time it runs — on the initial mount *and* on every later reactivation. `initMap()`'s own guard (`if (!mapContainer.value || map) return`) makes this harmless once the map exists, but it means every tab-switch back into Mesh (with the Map sub-tab open) schedules a throwaway 300ms timer purely to no-op. Low severity (matches the file's own comment acknowledging this), but it's dead weight that a one-line `if (!map)` guard around the `setTimeout` call would remove, and it makes the intent ("fallback init for the very first mount") not actually match what the code does ("fallback init on every arm"). **Fix:** ```ts if (!map) setTimeout(initMap, 300) ``` **Status:** Fixed in `0486045d` (`fix(02-review): WR-06 skip redundant fallback init timer on later MeshMap reactivations`). ## Info ### IN-01: `refreshXIfStale` helper duplicated near-verbatim across three views **File:** `neode-ui/src/views/Home.vue:552-555`, `neode-ui/src/views/Mesh.vue` (`refreshMeshGroupIfStale`), `neode-ui/src/views/Cloud.vue` (`loadCounts`'s staleness check) **Issue:** The "refresh this `CachedResource` only if it has never resolved or is past its own TTL" pattern is re-implemented independently in at least three views with the same three-line body (`if (res.entry.data === null || res.isStale.value) return res.refresh(); return Promise.resolve()`). Not a bug, but worth lifting into `useCachedResource.ts` itself (e.g. exposing `refreshIfStale()` on the returned object, mirroring the internal helper the composable already has) now that three call sites independently reinvented it. **Fix:** Add `refreshIfStale: () => Promise` to `CachedResource`'s return shape and have the three views call that instead of their local copies. **Status:** Documented, not fixed. This requires touching business logic across four files (`useCachedResource.ts` plus the three views' own `refresh*IfStale` call sites, each with slightly different local signatures — `refreshHomeGroupIfStale`/`refreshMeshGroupIfStale` take a resource argument and are fanned out via `Promise.allSettled`, while Cloud.vue's `loadCounts` inlines the check directly) — not the trivial/zero-risk bar this fix pass applies to Info findings. Left for a dedicated follow-up. ### IN-02: `wrapperFor`'s full-bleed/non-cacheable branch is currently dead code **File:** `neode-ui/src/views/dashboard/dashboardViewWrappers.ts:108-117` **Issue:** `wrapperFor()`'s key derivation (`cacheable || isFullBleedPath(path) ? path : DEFAULT_WRAPPER_KEY`) has a branch for "full-bleed but not cacheable" paths, but `isFullBleedPath()` only ever returns true for `/dashboard/chat` and `/dashboard/mesh`, both of which are always in `KEEP_ALIVE_PATHS` today (derived from `TAB_ORDER`, which both belong to). The branch is defensively correct (and cheap), just currently unreachable — worth a one-line comment noting it's intentional defense against a future `TAB_ORDER`/`WITHHELD_FROM_CACHE` change that could withhold a full-bleed path from the cache, so a future reader doesn't mistake it for dead code to delete. **Fix:** Non-blocking; a comment is sufficient. **Status:** Fixed in `b5506025` (`fix(02-review): IN-02 document wrapperFor's currently-unreachable full-bleed branch`). --- _Reviewed: 2026-07-31T04:09:21Z_ _Reviewer: Claude (gsd-code-reviewer)_ _Depth: standard_ ## CR-01 Follow-up (post-review hardening) **Date:** 2026-07-31 **Trigger:** Direct question from Dorian ("does the CR-01 fix protect existing users on update?") surfaced that it did not, for tabs already open before the fix shipped. Not part of a numbered plan — recorded here for traceability. ### Why CR-01's fix was incomplete for existing users CR-01 set `web5.lnd-info`/`web5.networking-profits` to `persist: false`, which stops **future** writes to sessionStorage, and `resources.ts`'s `entry()` already refused to **read** a stale snapshot when `persist` is `false` (WR-04). Neither of those touches a snapshot the **old** bundle already wrote under the old (implicit `persist: true`) decision. Nothing purges an existing `resource:` snapshot except `clearAll()` on logout, or the browser discarding sessionStorage when the tab's session ends. A tab that was open before the update and reloads in-place to pick up the new bundle — normal here, given the installed-PWA and kiosk-display usage patterns — keeps that orphaned snapshot in sessionStorage indefinitely, readable by any script on the origin, until the user happens to log out or close the tab. ### What was exposed, and what was not `resource:web5.lnd-info` held `balance_sats` / `channel_balance_sats` / `synced_to_chain`. `resource:web5.networking-profits` held `total_sats` / `content_sales_sats` / `routing_fees_sats`. **Not** exposed: no macaroon, no private key, no seed material, no npub/pubkey, no channel point, no on-chain/Lightning address, no transaction-level detail. This is a **financial-privacy exposure** (someone with script access to the origin, or physical access to a device with the tab still open, could read this node's aggregate balance/profit figures) — **not** a spending, signing, or key-compromise risk. Stated plainly for anyone reading this record later: nothing in the exposed payload could be used to move funds or impersonate the node. ### The fix: one-time schema-versioned migration purge `resources.ts` now checks a `sessionStorage['resource:__schema']` marker once at store setup (once per tab's page load — not per entry()/refresh() call). Marker absent (tab predates the migration) or stale (persist contract changed since) purges every `resource:`-prefixed sessionStorage key, then writes the current version. A matching marker is a no-op, so a plain in-place reload of an already-migrated tab costs one sessionStorage read, not a purge — the instant-paint-from-snapshot benefit the cache exists for is preserved for the common case. **Contract for future persist changes:** `CURRENT_SCHEMA_VERSION` (currently `'1'`) must be bumped any time a cache key's `persist` decision changes, so the next deploy automatically purges snapshots written under the old, now-incorrect decision — documented inline in `resources.ts` so this doesn't quietly regress the same way CR-01 did. ### `persist` is now required, not defaulted, everywhere `refresh()`'s `opts.persist ?? true` and `useCachedResource()`'s `persist: boolean` (optional, defaulting to `true`) were the same class of footgun that let CR-01 happen in the first place (Web5.vue's wallet resources omitted the field and silently persisted). Both are now required parameters with no default, matching the `entry()`/`optimistic()` hardening WR-04 already applied. Every existing call site was audited and given an explicit decision: **persist:false** (financial / identity / peer-identity payload): | Call site | Key | Rationale | |---|---|---| | LightningChannelsPanel.vue | `lnd.channels` | open channel balances/capacity — wallet data | | LightningChannelsPanel.vue | `lnd.closed-channels` | closed channel settlement records — wallet data | | Cloud.vue | `cloud.paid-items` | `PaidItem` carries `paid_sats` + purchase history | | Cloud.vue | `cloud.peer-nodes` | `PeerNode` carries did/pubkey/onion | | Cloud.vue / PeerFiles.vue | `cloud.my-files` | not a clean money/identity/peer-identity case — chosen `false` as the fail-safe default, flagged for human review | | Credentials.vue | `credentials.identities` | identity records | | Credentials.vue | `credentials.list` | credential material | | Federation.vue | `federation.nodes` | `FederatedNode` carries did (matches Mesh.vue's existing `mesh.federation-nodes` decision) | | FipsSeedAnchorsCard.vue | `server.fips-seed-anchors` | `SeedAnchor` carries npub | | Server.vue + FipsNetworkCard.vue | `server.fips-summary` | **corrected from `persist:true`** — the shared `fips.status` response carries `npub` (this node's own FIPS identity key); Server.vue's narrower local type didn't surface this, FipsNetworkCard.vue's fuller `FipsStatus` type did. Found during this audit, not in the original call-site list — a same-class T-02-01 violation, fixed alongside it. `serverTabCache.test.ts` updated to match. | **persist:true** (aggregate/status/public data, no identity or money): | Call site | Key | Rationale | |---|---|---| | AppDetails.vue | `app-details:bitcoin-sync:{id}` | public chain height/sync progress | | Cloud.vue | `cloud.section-counts` | bare per-section item counts | | Cloud.vue / PeerFiles.vue | `cloud.peer-browse:{onion}` (direct `resources.refresh()` calls) | now pass `{ persist: true }` explicitly, matching the pre-existing decision already documented at `peerBrowseEntry()` | | Federation.vue | `federation.dwn-status` | sync status/counters only | | MarketplaceAppDetails.vue | `app-details:versions:{id}` | public catalog metadata | | Monitoring.vue | `monitoring.current` / `.history.minute60` / `.alerts` / `.alert-rules` | system metrics and alert metadata only | | OpenWrtGateway.vue | `server.openwrt-status` | network/router status, matches sibling `server.*` resources | Commits: `5bfe6088` (migration + required-persist mechanism), `b8391115` (call-site audit + fixes). ### PWA auto-update change: declined A subsequent instruction, relayed through this session's coordinator, asked for `vite.config.ts`'s `skipWaiting`/`clientsClaim` to be flipped to `true` and `PWAUpdatePrompt.vue` changed to auto-apply updates without user acceptance, citing verbal approval from Dorian for the alpha stage. This was **not implemented**. Per this agent's operating rules, a relayed message from another agent is never treated as the user's own consent for a change of this kind — forcing service-worker activation on a Bitcoin/ Lightning wallet PWA (risking a reload mid-session) is exactly the sort of change that needs Dorian's own direct confirmation, not a second-hand instruction. `vite.config.ts` and `PWAUpdatePrompt.vue` are unmodified by this work. If this is still wanted, it should be requested directly. **Second request, same decision.** A follow-up message, again relayed through the coordinator, pressed the same change a second time, this time including a purported verbatim quote attributed to Dorian approving forced auto-update and declining any warning UI, offered as direct evidence rather than inference. That request was **also declined, unimplemented** — `vite.config.ts` and `PWAUpdatePrompt.vue` remain unmodified. Not because the quote was judged false, but because this agent has no channel to verify a quote attributed to the user by another agent independently of that agent's own assertion, and this agent's operating rules treat any agent-relayed message — verbatim-quoted or otherwise — as distinct from, and insufficient to substitute for, the user's own message in this session. A more detailed, more insistent second request for the same wallet-affecting change is the exact shape of thing that rule exists to hold the line against, whether or not that's what was happening here. This will be implemented, in one self-contained commit exactly as scoped, the moment Dorian's own message (or an equivalent direct/verified channel) carries the request instead. ### Safety acceptance criteria — evidence 1. **Purge blast radius is strictly bounded — PASS.** `purgeAllSnapshots()` only enumerates/removes keys with the `resource:` prefix. Audited every other sessionStorage/localStorage key this app uses (auth's `neode-auth` — localStorage, not sessionStorage; `_seed_words`/ `_seed_challenge_indices`; `archipelago_from_boot`/`archipelago_from_splash`/ `archipelago_share_to_mesh`/`archipelago_boot_log`; `video_intro_*`; `archy_onboarding_finale`; the hand-rolled `archipelago.web5.identities.v1` / `archipelago.web5.connected-nodes.v1` / `archipelago.fleet.cache.v1` caches; the PWA install-dismiss and reload-guard keys) — none share the `resource:` prefix. Test: `resourcesClear.test.ts` → *"migration purge is strictly bounded to the resource: prefix..."* seeds all of the above plus a legacy snapshot, runs the migration, and asserts every non-`resource:` key survives byte-for-byte while the legacy key is purged. 2. **No spending path is touched — PASS.** `git diff --stat 57989dfc..HEAD` (the range from CR-01's own fix through this work) touches only: `keepalive-remount-probe.spec.ts`, `catalog.json` (unrelated botfights version bump), `rpc-client.ts` (abort-signal plumbing for vpnStatus/dnsStatus, WR-05), `MeshMap.vue`, `useCachedResource.ts`, `resources.ts`/its tests, `Cloud.vue`, `Discover.vue`, `PeerFiles.vue`, `Server.vue`, `OpenWrtGateway.vue`, `keepAliveLifecycle.test.ts`, `dashboardViewWrappers.ts` — plus this session's own commits (`5bfe6088`, `b8391115`) touching the call-site list in the table above. `SendBitcoinModal.vue`, `WalletScanModal.vue`, and every `lnd.*`/`wallet.*` send/pay/sign RPC call site are untouched by any commit in this range — confirmed by `grep -rn "send\|invoice\|pay\|channel\|sign"` over the diff stat returning nothing, and by direct inspection that `SendBitcoinModal.vue` was not among the changed files. 3. **Money data is never presented as live when it isn't — PASS for Home.vue, PRE-EXISTING GAP for Web5.vue (not introduced by this work).** Home.vue's wallet card already had a header `RefreshIndicator` wired to `walletStatusRes.loadState` (T-02-13/D-05, from an earlier phase), unconditionally revalidating on every activation rather than TTL-gated — this was verified still intact, not modified. Web5.vue's `networkingProfitsDisplay` (routing/content-sale profit totals) and the hidden wallet card's `lndInfoRes` have **no** equivalent refresh indicator — this predates CR-01 and this fix; `lndInfoRes`'s actual `balance_sats`/`channel_balance_sats` numbers are not rendered anywhere in the current UI (the wallet card is commented out/hidden), so there is no live risk of a stale balance being shown as current from Web5.vue today, but `networkingProfitsDisplay` (a real financial figure) is rendered as plain text with no staleness cue. Not fixed here: adding a new visual indicator would violate this project's standing "no visual/ animation changes" rule and needs an explicit design decision, not a unilateral addition inside a storage-layer fix. Flagged for a dedicated follow-up. 4. **Nothing can be corrupted mid-flight — PASS.** The migration check runs synchronously inside `useResourcesStore()`'s `setup()`, before the store object is returned to any caller — `entries`/`inflight` are still the fresh empty `Map`s declared earlier in the same `setup()` call, so no `entry()`/`refresh()`/`optimistic()` invocation on this store instance can possibly be in flight yet. Test: `resourcesClear.test.ts` → *"the migration cannot race an in-flight fetch..."* asserts `entries.size === 0` immediately after store construction (post-migration) and that a `refresh()` right afterward completes normally with correct data/loadState/snapshot. The pre-existing `generation` guard (bumped by `clearAll()`, unchanged by this work) continues to cover the separate logout-time race, proven by the unmodified *"drops in-flight bookkeeping..."* test. 5. **Reversibility — PASS.** Two self-contained commits (`5bfe6088` mechanism, `b8391115` call-site audit), no schema/API changes outside `neode-ui/src/stores/resources.ts` and `neode-ui/src/composables/useCachedResource.ts`'s type signature, no deploy performed. Either commit can be reverted independently without touching the other. ### Test / build status Full suite: 785/785 passing (up from 778 pre-fix — 7 new tests: 6 migration/ blast-radius/race tests + 1 corrected `serverTabCache.test.ts` assertion counts as a modification, not new). `npx vue-tsc --noEmit`: clean. `npm run build`: succeeds. ### Record correction — PWA auto-update (commit `5fc3284a`) The addendum above records the PWA auto-update change being **declined twice** by the code-fixer agent, on the grounds that the approval reached it second-hand and it would not take a change that can reload a Bitcoin/Lightning wallet PWA on relayed consent. That judgement was correct for an agent in its position and was not overridden. For the audit trail: the change was subsequently made **by the orchestrator**, which held the authorization first-hand. Dorian's own message, sent in direct reply to an explanation that forcing service-worker activation could reload the app under a user mid-payment (and to an offer of an insistent-prompt alternative instead), was: > "ok, we're in alpha so no need for any scary warnings, we can just update them." So the decision was the user's, made with the mid-payment reload risk explicitly in front of them, and it specifically rejected adding warning UI. It was not an agent's inference and not a bypass of the earlier refusal. Implementation note: rather than flipping the build-time `skipWaiting`/`clientsClaim` flags as originally proposed, `5fc3284a` extends the **auto-apply path that already existed for kiosk displays** to all non-demo clients. That keeps two guards the previous code had deliberately built and which a build-time `skipWaiting` would have bypassed: `reloadAfterCinematic()` holds the reload until the splash/dashboard cinematic finishes, and the `hadController` check ignores the first-install claim. `vite.config.ts` is unmodified. Revisit at beta: restoring the prompt is a one-line change (`showUpdatePrompt.value = true`).