The code-fixer correctly declined this change twice on relayed consent. It was then made by the orchestrator holding first-hand authorization from Dorian, who chose forced auto-update with the mid-payment reload risk explicitly stated and rejected warning UI. Records the quote, and that the implementation reused the existing kiosk auto-apply path (preserving the cinematic and first-install guards) rather than flipping build-time skipWaiting. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
36 KiB
phase, reviewed, depth, files_reviewed, files_reviewed_list, findings, status, fixed_at, fix_status, fixed, documented_not_fixed
| phase | reviewed | depth | files_reviewed | files_reviewed_list | findings | status | fixed_at | fix_status | fixed | documented_not_fixed | ||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 02-ui-performance | 2026-07-31T04:09:21Z | standard | 26 |
|
|
issues_found | 2026-07-31T08:08:53Z | fixed_except_documented | 8 | 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:
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:
const profitsRes = useCachedResource<ProfitsData>({
key: 'web5.networking-profits',
fetcher: (signal) => rpcClient.call<ProfitsData>({ 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:
// Marketplace.vue
const catalogResource = useCachedResource<MarketplaceApp[]>({
key: 'app-catalog',
fetcher: async () => getCuratedAppList(), // static hardcoded list only
...
})
// Discover.vue
const catalogResource = useCachedResource<MarketplaceApp[]>({
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():
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:
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:
let pendingParams: Record<string, string> | undefined
const routerResource = useCachedResource<RouterStatus>({
key: 'server.openwrt-status',
fetcher: (signal) => rpcClient.call<RouterStatus>({
method: 'openwrt.get-status',
params: pendingParams ?? {},
...
}),
...
})
async function load(params?: Record<string, string>) {
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:
function entry<T>(key: string, persist = true): ResourceEntry<T> {
let e = entries.get(key)
if (!e) {
const snap = persist ? readSnapshot<T>(key) : null
e = reactive<ResourceEntry>({ ... })
entries.set(key, e)
}
return e as ResourceEntry<T>
}
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<T>(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:
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:
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<void> to CachedResource<T>'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
- Purge blast radius is strictly bounded — PASS.
purgeAllSnapshots()only enumerates/removes keys with theresource:prefix. Audited every other sessionStorage/localStorage key this app uses (auth'sneode-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-rolledarchipelago.web5.identities.v1/archipelago.web5.connected-nodes.v1/archipelago.fleet.cache.v1caches; the PWA install-dismiss and reload-guard keys) — none share theresource: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. - 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 everylnd.*/wallet.*send/pay/sign RPC call site are untouched by any commit in this range — confirmed bygrep -rn "send\|invoice\|pay\|channel\|sign"over the diff stat returning nothing, and by direct inspection thatSendBitcoinModal.vuewas not among the changed files. - 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
RefreshIndicatorwired towalletStatusRes.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'snetworkingProfitsDisplay(routing/content-sale profit totals) and the hidden wallet card'slndInfoReshave no equivalent refresh indicator — this predates CR-01 and this fix;lndInfoRes's actualbalance_sats/channel_balance_satsnumbers 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, butnetworkingProfitsDisplay(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. - Nothing can be corrupted mid-flight — PASS. The migration check runs
synchronously inside
useResourcesStore()'ssetup(), before the store object is returned to any caller —entries/inflightare still the fresh emptyMaps declared earlier in the samesetup()call, so noentry()/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..." assertsentries.size === 0immediately after store construction (post-migration) and that arefresh()right afterward completes normally with correct data/loadState/snapshot. The pre-existinggenerationguard (bumped byclearAll(), unchanged by this work) continues to cover the separate logout-time race, proven by the unmodified "drops in-flight bookkeeping..." test. - Reversibility — PASS. Two self-contained commits
(
5bfe6088mechanism,b8391115call-site audit), no schema/API changes outsideneode-ui/src/stores/resources.tsandneode-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).