fix(02-review): audit and set explicit persist on every useCachedResource call site
Demo images / Build & push demo images (push) Successful in 3m36s

Now that persist is required (no default) on useCachedResource()/refresh(),
every call site that previously relied on the implicit persist:true default
needs an explicit decision. Full audit, decision rule: money/identity/
peer-identity payloads -> false; static/aggregate/non-identifying data ->
true; ambiguous cases fail safe to false and are called out below.

persist:false (financial / identity / peer-identity payload):
- LightningChannelsPanel.vue: lnd.channels, lnd.closed-channels (open/closed
  Lightning channel balances — wallet data, same class as CR-01's lnd-info)
- Cloud.vue: cloud.paid-items (carries paid_sats + purchase history),
  cloud.peer-nodes (PeerNode carries did/pubkey/onion)
- Cloud.vue/PeerFiles.vue: cloud.my-files — not a clean money/identity/
  peer-identity case, but a private per-user file listing; chosen false as
  the fail-safe default per the audit rule, flagged here for review
- Credentials.vue: credentials.identities, credentials.list
- Federation.vue: federation.nodes (FederatedNode carries did — matches
  Mesh.vue's already-persist:false federation.nodes decision)
- FipsSeedAnchorsCard.vue: server.fips-seed-anchors (SeedAnchor carries npub)
- Server.vue + FipsNetworkCard.vue: server.fips-summary corrected from
  persist:true to persist:false — this shared cache key's real fips.status
  response carries npub (this node's own FIPS identity key), which
  Server.vue's narrower local type didn't surface but FipsNetworkCard.vue's
  fuller FipsStatus type does; both call sites must agree since a mismatch
  trips the dev-only entry() persist-consistency warning. Found during this
  audit, not part of the originally-scoped call-site list — corrected as a
  same-class T-02-01 violation. serverTabCache.test.ts updated to match.

persist:true (aggregate/status/public data, no identity or money):
- AppDetails.vue: app-details:bitcoin-sync (block height/sync progress)
- Cloud.vue: cloud.section-counts (bare per-section item counts);
  cloud.peer-browse (browsePeer()/loadCatalog()'s 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 (public catalog metadata)
- Monitoring.vue: monitoring.current/history/alerts/alert-rules (system
  metrics and alert metadata only)
- OpenWrtGateway.vue: server.openwrt-status (network/router status, matches
  sibling server.* resources)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-07-31 06:49:16 -04:00
co-authored by Claude Opus 5
parent 5bfe608893
commit b839111571
13 changed files with 51 additions and 8 deletions
@@ -479,6 +479,7 @@ const channelsRes = useCachedResource<ChannelsData>({
total_outbound: result?.total_outbound || 0, total_outbound: result?.total_outbound || 0,
} }
}, },
persist: false, // open channel balances/capacity — wallet data (T-02-01)
}) })
const closedRes = useCachedResource<ClosedChannel[]>({ const closedRes = useCachedResource<ClosedChannel[]>({
key: 'lnd.closed-channels', key: 'lnd.closed-channels',
@@ -488,6 +489,7 @@ const closedRes = useCachedResource<ClosedChannel[]>({
}) })
return closed?.channels || [] return closed?.channels || []
}, },
persist: false, // closed channel settlement records — wallet data (T-02-01)
}) })
const loading = computed(() => const loading = computed(() =>
channelsRes.loadState.value === 'loading' || channelsRes.loadState.value === 'refreshing') channelsRes.loadState.value === 'loading' || channelsRes.loadState.value === 'refreshing')
+1
View File
@@ -177,6 +177,7 @@ const bitcoinSyncResource = useCachedResource<{ block_height: number; sync_progr
timeout: 5000, timeout: 5000,
}), }),
ttlMs: 30_000, // install/health-state-shaped data, per plan default ttlMs: 30_000, // install/health-state-shaped data, per plan default
persist: true, // public chain height/sync progress — no money amount or identity
immediate: false, // kicked from onMounted, gated on needsBitcoinSync immediate: false, // kicked from onMounted, gated on needsBitcoinSync
}) })
const bitcoinSyncPercent = computed(() => (bitcoinSyncResource.data.value?.sync_progress ?? 0) * 100) const bitcoinSyncPercent = computed(() => (bitcoinSyncResource.data.value?.sync_progress ?? 0) * 100)
+9 -1
View File
@@ -428,6 +428,7 @@ const countsResource = useCachedResource<Record<string, number>>({
key: 'cloud.section-counts', key: 'cloud.section-counts',
fetcher: fetchCounts, fetcher: fetchCounts,
ttlMs: 30_000, ttlMs: 30_000,
persist: true, // bare per-section item counts — no file names/content/identity
immediate: false, // gated on fileBrowserRunning; kicked from onMounted/watch immediate: false, // gated on fileBrowserRunning; kicked from onMounted/watch
}) })
const sectionCounts = computed(() => countsResource.entry.data ?? {}) const sectionCounts = computed(() => countsResource.entry.data ?? {})
@@ -460,6 +461,7 @@ const paidResource = useCachedResource<PaidItem[]>({
const res = await rpcClient.call<{ items: PaidItem[] }>({ method: 'content.owned-list', signal, dedup: true }) const res = await rpcClient.call<{ items: PaidItem[] }>({ method: 'content.owned-list', signal, dedup: true })
return (res.items || []).slice().reverse() return (res.items || []).slice().reverse()
}, },
persist: false, // PaidItem carries paid_sats + purchase history — financial data (T-02-01)
immediate: false, // loaded when the Paid tab is opened immediate: false, // loaded when the Paid tab is opened
}) })
const paidItems = computed(() => paidResource.entry.data ?? []) const paidItems = computed(() => paidResource.entry.data ?? [])
@@ -517,6 +519,7 @@ const peersResource = useCachedResource<PeerNode[]>({
return result?.nodes ?? [] return result?.nodes ?? []
}, },
ttlMs: 30_000, ttlMs: 30_000,
persist: false, // PeerNode carries did/pubkey/onion — peer identity payload (T-02-01)
immediate: false, // kicked from onMounted (keeps the legacy load order) immediate: false, // kicked from onMounted (keeps the legacy load order)
}) })
const peerNodes = computed(() => peersResource.entry.data ?? []) const peerNodes = computed(() => peersResource.entry.data ?? [])
@@ -632,6 +635,11 @@ const myFilesResource = useCachedResource<FileBrowserItem[]>({
key: 'cloud.my-files', key: 'cloud.my-files',
fetcher: fetchMyFiles, fetcher: fetchMyFiles,
ttlMs: 60_000, ttlMs: 60_000,
// Fail-safe choice, not an obvious money/identity/peer-identity case: a
// flat listing of the user's own filenames/paths is private content, not
// squarely one of the three T-02-01 categories. Flagged for human review
// rather than defaulted to persist:true.
persist: false,
immediate: false, // loaded when the My Files tab (or search) needs it immediate: false, // loaded when the My Files tab (or search) needs it
}) })
const myFiles = computed(() => myFilesResource.entry.data ?? []) const myFiles = computed(() => myFilesResource.entry.data ?? [])
@@ -802,7 +810,7 @@ function browsePeer(peer: PeerNode): Promise<void> {
signal: browsePeerAborter.signal, signal: browsePeerAborter.signal,
}) })
return { items: res?.items ?? [], transport: res?.transport ?? null, latencyMs: Date.now() - t0 } return { items: res?.items ?? [], transport: res?.transport ?? null, latencyMs: Date.now() - t0 }
}) }, { persist: true }) // matches peerBrowseEntry()'s explicit persist:true for this same key
// A timed-out/failed peer resolves through resources.ts's own catch path // A timed-out/failed peer resolves through resources.ts's own catch path
// (loadState -> 'error', no throw here) — it never blocks or delays any // (loadState -> 'error', no throw here) — it never blocks or delays any
// other peer's slot, and the UI already surfaces this silently (the // other peer's slot, and the UI already surfaces this silently (the
+2
View File
@@ -239,6 +239,7 @@ const identitiesRes = useCachedResource<Identity[]>({
}) })
return result?.identities || [] return result?.identities || []
}, },
persist: false, // identity records — identity payload (T-02-01)
}) })
const credentialsRes = useCachedResource<Credential[]>({ const credentialsRes = useCachedResource<Credential[]>({
key: 'credentials.list', key: 'credentials.list',
@@ -248,6 +249,7 @@ const credentialsRes = useCachedResource<Credential[]>({
}) })
return result?.credentials || [] return result?.credentials || []
}, },
persist: false, // credential material — identity payload (T-02-01)
}) })
const identities = computed(() => identitiesRes.data.value ?? []) const identities = computed(() => identitiesRes.data.value ?? [])
const credentials = computed(() => credentialsRes.data.value ?? []) const credentials = computed(() => credentialsRes.data.value ?? [])
+2
View File
@@ -257,6 +257,7 @@ const syncStore = useSyncStore()
const nodesRes = useCachedResource<FederatedNode[]>({ const nodesRes = useCachedResource<FederatedNode[]>({
key: 'federation.nodes', key: 'federation.nodes',
fetcher: async () => (await rpcClient.federationListNodes()).nodes, fetcher: async () => (await rpcClient.federationListNodes()).nodes,
persist: false, // FederatedNode carries did/pubkey/onion — peer identity payload (T-02-01)
}) })
const nodes = computed(() => nodesRes.data.value ?? []) const nodes = computed(() => nodesRes.data.value ?? [])
const loading = computed(() => nodesRes.loadState.value === 'loading') const loading = computed(() => nodesRes.loadState.value === 'loading')
@@ -331,6 +332,7 @@ const mapLinks = computed(() => {
const dwnStatusRes = useCachedResource<DwnStatus>({ const dwnStatusRes = useCachedResource<DwnStatus>({
key: 'federation.dwn-status', key: 'federation.dwn-status',
fetcher: (signal) => rpcClient.call<DwnStatus>({ method: 'dwn.status', signal, dedup: true, maxRetries: 1 }), fetcher: (signal) => rpcClient.call<DwnStatus>({ method: 'dwn.status', signal, dedup: true, maxRetries: 1 }),
persist: true, // sync status/counters only — no identity payload
immediate: false, immediate: false,
}) })
const dwnStatus = computed(() => dwnStatusRes.data.value) const dwnStatus = computed(() => dwnStatusRes.data.value)
+4 -2
View File
@@ -415,8 +415,9 @@ const appId = computed(() => route.params.id as string)
// Keyed per marketplace app id (D-04). Catalog version metadata is // Keyed per marketplace app id (D-04). Catalog version metadata is
// near-static, so this can take a longer TTL than the 30s default; it holds // near-static, so this can take a longer TTL than the 30s default; it holds
// nothing credential/DID/wallet/tx-history-shaped so persist stays default // nothing credential/DID/wallet/tx-history-shaped so persist:true is safe
// (true). Note per 02-FINDINGS.md: this is the only one of MarketplaceAppDetails's // (explicit, not defaulted CR-01 follow-up removed the implicit default).
// Note per 02-FINDINGS.md: this is the only one of MarketplaceAppDetails's
// captured RPC calls that isn't confounded by the Home-tab-transit artifact // captured RPC calls that isn't confounded by the Home-tab-transit artifact
// getCurrentApp() is a synchronous store read (no RPC) and the bitcoin-prune // getCurrentApp() is a synchronous store read (no RPC) and the bitcoin-prune
// check below is a plain fetch(), not an rpcClient call. // check below is a plain fetch(), not an rpcClient call.
@@ -430,6 +431,7 @@ const versionsResource = useCachedResource<PackageVersionsResponse>({
timeout: 15000, timeout: 15000,
}), }),
ttlMs: 120_000, ttlMs: 120_000,
persist: true, // package version list public catalog metadata, no identity/money
immediate: false, immediate: false,
}) })
+4
View File
@@ -303,6 +303,7 @@ const currentRes = useCachedResource<MetricSnapshot>({
if (!data || !('system' in data)) throw new Error('metrics not ready') if (!data || !('system' in data)) throw new Error('metrics not ready')
return data return data
}, },
persist: true, // system/container/rpc metrics no identity/money payload
}) })
const historyRes = useCachedResource<MetricSnapshot[]>({ const historyRes = useCachedResource<MetricSnapshot[]>({
key: 'monitoring.history.minute60', key: 'monitoring.history.minute60',
@@ -313,6 +314,7 @@ const historyRes = useCachedResource<MetricSnapshot[]>({
}) })
return data?.data ?? [] return data?.data ?? []
}, },
persist: true, // historical metric snapshots no identity/money payload
}) })
const alertsRes = useCachedResource<FiredAlert[]>({ const alertsRes = useCachedResource<FiredAlert[]>({
key: 'monitoring.alerts', key: 'monitoring.alerts',
@@ -322,6 +324,7 @@ const alertsRes = useCachedResource<FiredAlert[]>({
}) })
return (data?.alerts ?? []).reverse() return (data?.alerts ?? []).reverse()
}, },
persist: true, // alert metadata (kind/message/threshold) no identity/money payload
}) })
const alertRulesRes = useCachedResource<AlertRule[]>({ const alertRulesRes = useCachedResource<AlertRule[]>({
key: 'monitoring.alert-rules', key: 'monitoring.alert-rules',
@@ -331,6 +334,7 @@ const alertRulesRes = useCachedResource<AlertRule[]>({
}) })
return data?.rules ?? [] return data?.rules ?? []
}, },
persist: true, // static alert-rule config no identity/money payload
}) })
const current = computed(() => currentRes.data.value) const current = computed(() => currentRes.data.value)
const history = computed(() => historyRes.data.value ?? []) const history = computed(() => historyRes.data.value ?? [])
+1 -1
View File
@@ -901,7 +901,7 @@ function loadCatalog(): Promise<void> {
dedup: true, dedup: true,
}) })
return { items: result?.items ?? [], transport: result?.transport ?? null, latencyMs: Date.now() - t0 } return { items: result?.items ?? [], transport: result?.transport ?? null, latencyMs: Date.now() - t0 }
}) }, { persist: true }) // matches peerBrowseEntry()'s explicit persist:true for this same key
} }
// Load visual previews for image and video items when catalog loads. // Load visual previews for image and video items when catalog loads.
+11 -2
View File
@@ -495,12 +495,21 @@ const networkRefreshing = computed(() => networkRes.loadState.value === 'refresh
// TTL 60s (near-static: installed/service_active/key_present rarely change, // TTL 60s (near-static: installed/service_active/key_present rarely change,
// but authenticated_peer_count can drift, so this sits between the fast // but authenticated_peer_count can drift, so this sits between the fast
// 10s tier and Tor/VPN's 30s tier rather than a fully-static value). // 10s tier and Tor/VPN's 30s tier rather than a fully-static value).
// persist:true key_present is a boolean flag, not the key material itself. // persist:false (CR-01 follow-up audit correction) this component's own
// local type only names installed/service_active/key_present/etc, but
// `fips.status` is the SAME RPC/cache key FipsNetworkCard.vue shares
// ("Shares `server.fips-summary`..."), and THAT component's fuller
// `FipsStatus` type shows the real response also carries `npub` this
// node's own FIPS identity public key. Whichever of the two fetchers wins a
// given refresh writes the FULL raw response to sessionStorage regardless
// of either caller's narrower TS type, so this key must follow the same
// "own identity never persist" rule as mesh.self-onion/mesh.self-did
// (T-02-01), not the "aggregate status only" rule its old comment assumed.
const fipsSummaryRes = useCachedResource<{ installed: boolean; service_active: boolean; key_present: boolean; anchor_connected?: boolean; authenticated_peer_count?: number }>({ const fipsSummaryRes = useCachedResource<{ installed: boolean; service_active: boolean; key_present: boolean; anchor_connected?: boolean; authenticated_peer_count?: number }>({
key: 'server.fips-summary', key: 'server.fips-summary',
immediate: false, immediate: false,
ttlMs: 60_000, ttlMs: 60_000,
persist: true, persist: false,
fetcher: (signal) => rpcClient.call({ method: 'fips.status', signal, dedup: true, maxRetries: 1 }), fetcher: (signal) => rpcClient.call({ method: 'fips.status', signal, dedup: true, maxRetries: 1 }),
}) })
const fipsSummary = computed(() => fipsSummaryRes.data.value) const fipsSummary = computed(() => fipsSummaryRes.data.value)
@@ -232,7 +232,7 @@ describe('Server tab cache (Task 1): seven load groups', () => {
wrapper.unmount() wrapper.unmount()
}) })
it('groups carrying VPN peer identity or Tor onion addresses declare persist:false; non-identity groups may persist', async () => { it('groups carrying VPN peer identity, Tor onion addresses, or this node\'s own FIPS identity key declare persist:false; non-identity groups may persist', async () => {
const wrapper = mountServerHost() const wrapper = mountServerHost()
await flushPromises() await flushPromises()
@@ -246,7 +246,13 @@ describe('Server tab cache (Task 1): seven load groups', () => {
expect(readSnapshot('server.vpn-peers')).toBeNull() // carries npub (peer identity) expect(readSnapshot('server.vpn-peers')).toBeNull() // carries npub (peer identity)
expect(readSnapshot('server.tor-services')).toBeNull() // carries onion_address expect(readSnapshot('server.tor-services')).toBeNull() // carries onion_address
expect(readSnapshot('server.network-summary')).not.toBeNull() // this node's own status expect(readSnapshot('server.network-summary')).not.toBeNull() // this node's own status
expect(readSnapshot('server.fips-summary')).not.toBeNull() // CR-01 follow-up correction: `server.fips-summary` is shared with
// FipsNetworkCard.vue, whose fuller FipsStatus type shows the real
// `fips.status` response also carries `npub` — this node's own FIPS
// identity public key — so this key must be persist:false too (T-02-01),
// not persist:true as originally assumed when only the narrower
// installed/service_active/key_present fields were considered.
expect(readSnapshot('server.fips-summary')).toBeNull()
expect(readSnapshot('server.interfaces')).not.toBeNull() expect(readSnapshot('server.interfaces')).not.toBeNull()
expect(readSnapshot('server.disk-status')).not.toBeNull() expect(readSnapshot('server.disk-status')).not.toBeNull()
@@ -139,9 +139,14 @@ interface FipsStatus {
// Shares `server.fips-summary` with the Local Network card's FIPS row, so // Shares `server.fips-summary` with the Local Network card's FIPS row, so
// both paint from the same cache instantly on revisit and never disagree. // both paint from the same cache instantly on revisit and never disagree.
// persist:false (T-02-01) the response carries `npub`, this node's own
// FIPS identity public key; must match Server.vue's persist decision for
// this same key exactly (a mismatch would trip the dev-only entry() warning
// and leave it ambiguous which decision actually governs the shared cache).
const statusRes = useCachedResource<FipsStatus>({ const statusRes = useCachedResource<FipsStatus>({
key: 'server.fips-summary', key: 'server.fips-summary',
ttlMs: 15_000, ttlMs: 15_000,
persist: false,
fetcher: (signal) => rpcClient.call<FipsStatus>({ method: 'fips.status', signal, dedup: true, maxRetries: 1 }), fetcher: (signal) => rpcClient.call<FipsStatus>({ method: 'fips.status', signal, dedup: true, maxRetries: 1 }),
}) })
const status = computed<FipsStatus>(() => statusRes.data.value ?? { const status = computed<FipsStatus>(() => statusRes.data.value ?? {
@@ -116,6 +116,7 @@ const anchorsRes = useCachedResource<SeedAnchor[]>({
}) })
return res.seed_anchors return res.seed_anchors
}, },
persist: false, // SeedAnchor carries an npub (peer identity key material) T-02-01
}) })
const anchors = computed(() => anchorsRes.data.value ?? []) const anchors = computed(() => anchorsRes.data.value ?? [])
const adding = ref(false) const adding = ref(false)
@@ -93,6 +93,7 @@ const routerResource = useCachedResource<RouterStatus>({
maxRetries: 1, maxRetries: 1,
}), }),
ttlMs: 30_000, ttlMs: 30_000,
persist: true, // router/network status (host/uptime/wifi/wan) no identity/money payload
immediate: false, // initial fetch is gated explicitly in onMounted below immediate: false, // initial fetch is gated explicitly in onMounted below
}) })
const status = computed(() => routerResource.data.value) const status = computed(() => routerResource.data.value)