Files
archy/.planning/phases/01-federation-mesh-hardening/01-14-PLAN.md

21 KiB
Raw Permalink Blame History

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, gap_closure, must_haves
phase plan type wave depends_on files_modified autonomous requirements gap_closure must_haves
01-federation-mesh-hardening 14 execute 7
neode-ui/src/composables/usePaidItemViewer.ts
neode-ui/src/views/Cloud.vue
neode-ui/src/composables/__tests__/usePaidItemViewer.test.ts
true
UIFIX-04
UIFIX-06
true
truths prohibitions artifacts key_links
Clicking a purchased picture in Paid Files opens it in the app's own lightbox — no browser tab, matching how My Files already behaves (UIFIX-04)
A purchased video opens in the same lightbox with its player controls, consistent with every other video in the app
A purchased audio track still goes to the global bottom-bar player and never to the lightbox, exactly as today (UIFIX-04 adjacency edge)
A purchased file with no in-app viewer (a document) still opens the way it does today rather than failing silently — the change adds a viewer path, it does not remove one
The row shows a house loading state for the whole time the purchased file is being fetched, so a slow open never looks like a dead click (UIFIX-06)
A fetch that fails or times out surfaces the existing error treatment instead of being swallowed, and the row's loading state clears (UIFIX-06 failure-surfacing)
Clicking the same purchased item twice in quick succession produces one fetch, not two (UIFIX-04 concurrency edge)
Every surface named by phase 2's findings as slow-opening has a recorded verdict — an existing loader confirmed, or a missing one added (UIFIX-06)
A cached revisit still shows no spinner: loaders are driven by a first load, never by a background refresh, preserving PERF-02
statement category
The viewer path MUST NOT re-charge, re-purchase, or re-request payment for content the buyer already owns — opening a purchased item reads the local owned cache and nothing else safety
statement category
Purchased bytes MUST NOT outlive the viewing session as a reachable object URL — every URL this path creates is revoked by whichever component owns it, with exactly one owner per URL privacy
statement category
No loading affordance may be added to a path that is already instant or already cached — a spinner on a cached revisit is a PERF-02 regression, not a UIFIX-06 fix transparency
path provides contains
neode-ui/src/composables/usePaidItemViewer.ts Fetch, decode, route-to-viewer and loading/error state for a purchased item opening
path provides min_lines
neode-ui/src/composables/__tests__/usePaidItemViewer.test.ts Per-mime routing, loading state, error surfacing and double-click dedup 60
from to via pattern
neode-ui/src/views/Cloud.vue neode-ui/src/components/cloud/MediaLightbox.vue a paid-items lightbox instance fed synthetic FileBrowserItem entries and a resolver that returns the already-fetched object URL MediaLightbox
Make a purchased picture open where every other picture in the app opens — the lightbox — and make the wait visible while it loads.

Purpose: two user-reported issues that phase 2 classified as pre-existing and captured rather than fixed. Cloud.vue's viewPaidItem() calls window.open(url, '_blank', 'noopener') (introduced f3393581, 2026-07-22), so Paid Files is the one media surface that leaves the app. The same function issues content.owned-get with a 60-second timeout and renders no loading affordance at all, and its catch swallows every failure — so a slow or failed open is indistinguishable from a click that did nothing. Both live in the same twelve lines, so they are fixed together. Output: a small viewer composable with tested per-mime routing, a paid-items lightbox in Cloud, an inline house loading state on the row, real error surfacing, and a recorded verdict for every other surface phase 2 flagged as slow.

<execution_context> @$HOME/.claude/gsd-core/workflows/execute-plan.md @$HOME/.claude/gsd-core/templates/summary.md </execution_context>

@.planning/PROJECT.md @.planning/STATE.md @.planning/phases/01-federation-mesh-hardening/01-UI-SPEC.md @neode-ui/src/components/cloud/MediaLightbox.vue

Artifacts this phase produces

Created or changed by this plan:

Symbol Kind File
usePaidItemViewer() new composable — fetch, decode, route, state neode-ui/src/composables/usePaidItemViewer.ts
paid-items MediaLightbox instance + row loading state changed template neode-ui/src/views/Cloud.vue
viewPaidItem delegating to the composable changed script same
neode-ui/src/composables/__tests__/usePaidItemViewer.test.ts new vitest suite new file
Task 1: End-to-end — a purchased picture opens in the lightbox, with the wait visible neode-ui/src/composables/usePaidItemViewer.ts, neode-ui/src/views/Cloud.vue, neode-ui/src/composables/__tests__/usePaidItemViewer.test.ts - `neode-ui/src/views/Cloud.vue` lines 150-178 (the Paid Files tab rows: each row is a `glass-card p-3 flex items-center gap-3 cursor-pointer` div with `@click="viewPaidItem(it)"`, an emoji type glyph, filename, size/sats/date line, and a "Paid" pill), lines 456-495 (the `PaidItem` interface, the `paidResource` cached resource with `persist: false`, and `viewPaidItem` itself — the `content.owned-get` call with `timeout: 60000`, the base64→Uint8Array →Blob→`URL.createObjectURL` chain, the audio branch that hands off to `useAudioPlayer`, the `window.open` call, the 60-second revoke timer and the empty `catch`), lines 390-400 (the existing `MediaLightbox` instance for own files, showing exactly which props it takes: `items`, `start-index`, `show`, `fetch-blob-url`, `stream-url`, and `@close`), and lines 696-731 (`lightboxIndex`/`lightboxItems` refs and `handlePreview`, the working example of driving that component). Also note `loadError` and the `alert-error` block at line 369 — the error surface this plan reuses rather than inventing one. - `neode-ui/src/components/cloud/MediaLightbox.vue` — the whole file. What matters: it takes `items: FileBrowserItem[]` and filters them by extension through `getFileCategory`, so a synthetic item's `name` must carry a real extension; it calls `props.fetchBlobUrl(item.path)` for images and `props.streamUrl(item.path)` for video/audio when supplied; it caches returned URLs in its own `urlCache` and revokes every one of them in `onUnmounted`. That last point decides URL ownership: whatever this plan hands to the lightbox must not also be revoked by Cloud. - `neode-ui/src/views/PeerFiles.vue` lines 172-190 — the existing house treatment for exactly this interaction: a per-item `playing === item.id` guard rendering a 3×3 border spinner with an "Opening..." label inside the button. Reuse this treatment; do not invent a new one. - `neode-ui/src/api/filebrowser-client.ts` — the `FileBrowserItem` shape, so the synthetic item is structurally valid rather than cast. - `neode-ui/src/composables/useAudioPlayer.ts` — the `play(url, name)` contract the audio branch already uses. - Given a purchased item with an `image/*` mime, `open()` fetches it once, then exposes it as a lightbox item with a resolvable object URL; it does not call `window.open`. - Given a `video/*` mime, same — routed to the lightbox. - Given an `audio/*` mime, `open()` routes to the audio player and never to the lightbox. - Given a mime with no in-app viewer, `open()` falls back to the existing browser-tab behaviour. - `opening` is set to the item's key for the whole duration of the fetch and cleared in every exit path, including the failure path. - A rejected or timed-out fetch sets an error message and clears `opening`; it does not throw past the caller. - Calling `open()` twice for the same item while the first call is in flight issues one RPC. - The synthetic lightbox item's `name` ends in the real file extension so the lightbox's own category filter accepts it. Write the test file first and confirm it fails. Stub the RPC client and `URL.createObjectURL`/ `atob` at the module boundary; jsdom has no real blob decoding, so assert on what was requested and what was routed where, not on byte content.
Create `neode-ui/src/composables/usePaidItemViewer.ts` exporting `usePaidItemViewer()` returning at
least: `opening` (a ref holding the key of the item currently being fetched, or null), `error` (a
ref holding a user-facing message or null), `lightboxItems`, `lightboxIndex`, `resolveBlobUrl(path)`
and `open(item)`. Move the existing fetch-and-decode chain out of `Cloud.vue` verbatim — same RPC
method, same params, same 60-second timeout, same base64 decode, same blob construction. Then
branch on the resolved mime: audio hands off to `useAudioPlayer` exactly as today; image and video
build a synthetic `FileBrowserItem` (a stable synthetic `path` key, a `name` that is the item's
basename so its extension survives, `isDir: false`, and the size from the item), register the
created object URL against that path in an internal map that `resolveBlobUrl` reads, and set
`lightboxItems`/`lightboxIndex` to show it; anything else keeps today's browser-tab behaviour
including its existing revoke timer.

URL ownership, stated once so there is exactly one owner: URLs handed to the lightbox are revoked
by the lightbox on unmount — the composable must not schedule a revoke for those. URLs handed to
the audio player keep today's behaviour. URLs opened in a browser tab keep today's revoke timer.

Guard concurrency by keying on the item and returning early when that key is already in `opening`.
Replace the empty `catch` with one that sets `error` to a short user-facing message (reuse the tone
of the existing copy in this view) and clears `opening` in a `finally`.

In `Cloud.vue`: import the composable, delete the old `viewPaidItem` body and delegate to
`open(it)`, and wire two things into the template. First, the Paid Files row gets the PeerFiles
loading treatment — while `opening` matches that row's key, render the 3×3 border spinner and an
"Opening…" label in place of the "Paid" pill, and make the row non-interactive for the duration so
a second click cannot queue. Second, add a second `MediaLightbox` instance below the existing one,
bound to the composable's `lightboxItems`/`lightboxIndex`, with `fetch-blob-url` and `stream-url`
both pointing at `resolveBlobUrl`, and `@close` clearing the composable's index. Surface `error`
through the view's existing `loadError` alert rather than adding a new error element.

Change nothing else in `Cloud.vue` — not the tab strip, not the category pills, not the Folders,
My Files or Peer Files sections, not the peer cards, not the existing own-files lightbox instance,
not any cached-resource key, TTL or `persist` flag.
cd neode-ui && test -f src/composables/__tests__/usePaidItemViewer.test.ts && npx vitest run src/composables/__tests__/usePaidItemViewer.test.ts - The test file exists and `cd neode-ui && npx vitest run src/composables/__tests__/usePaidItemViewer.test.ts` exits 0 (the `test -f` guard is required — `vitest.config.ts` sets `passWithNoTests: true`). - The suite contains a case per mime family — image, video, audio, and no-in-app-viewer — plus a loading-state case, an error case and a double-click dedup case. - `grep -c 'window.open' neode-ui/src/views/Cloud.vue` equals 0. - `grep -c 'MediaLightbox' neode-ui/src/views/Cloud.vue` is at least 3 (import plus two instances). - `grep -c 'usePaidItemViewer' neode-ui/src/views/Cloud.vue` is at least 2. - `git diff -- neode-ui/src/views/Cloud.vue | grep -c "^-.*key: 'cloud\."` equals 0 — no cached-resource key was moved or renamed. - `cd neode-ui && npx vitest run` exits 0 — every existing suite stays green. - `cd neode-ui && npm run build` exits 0 and `grep -rq 'usePaidItemViewer\|Opening…' ../web/dist/neode-ui/assets/` succeeds (per CLAUDE.md the frontend build can silently no-op). Purchased pictures and videos open in the app lightbox with a visible wait and a real error path; audio and documents behave exactly as before. Task 2: Settle the slow-open inventory — verdict per surface, loader only where genuinely missing neode-ui/src/composables/__tests__/usePaidItemViewer.test.ts - `.planning/phases/02-ui-performance/02-FINDINGS.md` — the `## Outstanding` section and the `### Per-surface verdict` block under `## Re-measurement (gap closure)`. These name the surfaces to audit: Discover, Server, Web5, Fleet, AppDetails, OpenWrtGateway, MarketplaceAppDetails and Wallet-send. Read them for what each surface's cost actually is — several are *revisit* regressions on already-cached views, which is the one case where a loader would be a PERF-02 regression rather than a fix. - `neode-ui/src/components/RefreshIndicator.vue` — the whole file, including its doc comment: it renders only in the `refreshing` state and deliberately renders nothing for `loading`, because a first load is the view's own skeleton's job. This is the rule that decides which affordance a surface needs. - `neode-ui/src/components/SkeletonCard.vue` — the house first-load skeleton, and `neode-ui/src/components/cloud/FileGrid.vue`'s skeleton block for the grid variant. - `.planning/phases/02-ui-performance/02-CONTEXT.md` — the D-05 rules on what a background-refresh indicator may and may not show, so anything added here matches decisions already locked. Audit each surface named above. For each one, determine two things from the code: does a first open (no cache) render a loading affordance today, and is the open genuinely slow and uncached rather than a cached revisit. Record a one-line verdict per surface in the SUMMARY as a table with columns: surface, file, existing affordance, genuinely slow first open, action taken.
Add a house loading affordance only where the audit proves both a genuinely slow uncached first
open and no existing affordance — a `SkeletonCard`/`FileGrid`-style skeleton for a list or grid, a
`RefreshIndicator` only for background revalidation. Never gate a new affordance on a
`refreshing` state for a first load, and never add one to a cached revisit path; PERF-02's
no-spinner-on-revisit guarantee outranks this requirement wherever they meet, and phase 2's
verdict is that the named revisit regressions are client-side render cost, not a missing loader.

If a surface needs a fix, implement it in this plan and add its file to `files_modified` in the
SUMMARY. If every surface already has one — which the planner's own read of these files suggests
is likely, with `Cloud.vue`'s paid-open being the single genuine gap — say so explicitly with the
evidence, and do not add a loader for its own sake. A verdict of "already covered" is a valid
outcome; an unrecorded surface is not.

Extend the test file with a case pinning that the paid-open loading state is driven by the fetch
being in flight and not by any cached-resource `refreshing` state, so a later refactor cannot turn
it into a revisit spinner.
cd neode-ui && npx vitest run src/composables/__tests__/usePaidItemViewer.test.ts && npx vitest run && npm run build - `cd neode-ui && npx vitest run` exits 0 and `cd neode-ui && npm run build` exits 0. - The SUMMARY contains the per-surface verdict table with a row for every surface named in `02-FINDINGS.md`'s outstanding list, each with a file path and an explicit action. - Every surface where the action is "loader added" names the file, and that file appears in the SUMMARY's `files_modified` addendum. - The SUMMARY states explicitly that no affordance was added to a cached-revisit path, naming PERF-02. - The test suite contains the case pinning that the paid-open loading state is not derived from a `refreshing` state. Every flagged surface has an evidence-backed verdict, and the only loaders added are on genuinely slow uncached opens.

Planner Assumptions (flagged, unresolved)

  • Documents keep the browser-tab path. UIFIX-04's text names pictures, and the app has no in-app document viewer; routing a PDF into a media lightbox would be a downgrade, not a fix. This is a deliberate scope boundary, not an omission — recorded here so it is visible rather than silent. If the user wants documents in-app too, that is a new requirement, not a gap in this one.
  • The planner's read suggests every other named surface already has a loading affordance (grep showed loading/skeleton markup in Server, Web5, Fleet, AppDetails, MarketplaceAppDetails, Marketplace, Apps, OpenWrtGateway and the Discover app grid). Task 2 re-verifies rather than assuming, because a grep hit is not proof that the affordance covers the first uncached open.

<threat_model>

Trust Boundaries

Boundary Description
peer-supplied purchased bytes → app-origin viewer Content bought from another node is now rendered inside the app origin instead of a separate tab
purchase records → rendered row Paid amounts and purchase history are financial data already marked persist: false

STRIDE Threat Register

Threat ID Category Component Severity Disposition Mitigation Plan
T-01-62 Elevation of Privilege peer-supplied bytes rendered in-origin instead of an isolated tab high mitigate The content is delivered as a blob object URL with the mime the backend reports and rendered only through <img>/<video> elements the lightbox already uses for local files; no srcdoc, no innerHTML, no iframe, and the no-in-app-viewer branch keeps today's separate-tab behaviour for anything that is not an image or a video
T-01-63 Information Disclosure a purchased-content object URL outliving the view and remaining fetchable medium mitigate The prohibition fixes exactly one owner per URL; the lightbox revokes what it is given on unmount, and the composable is forbidden from scheduling a competing revoke for those
T-01-64 Repudiation a failed open being indistinguishable from a click that did nothing medium mitigate The empty catch is replaced with one that sets a user-facing error through the view's existing alert, and a test case asserts the failure path both surfaces and clears state
T-01-65 Denial of Service repeated clicks queuing multiple 60-second fetches of large purchased files medium mitigate The opening key guard returns early for an in-flight item, the row is made non-interactive while loading, and a test case pins single-fetch behaviour
T-01-66 Spoofing a purchased item's declared mime steering it to the wrong viewer low accept Mime comes from the same backend response the current code already trusts for its blob type; this plan changes routing, not provenance, and tightening mime provenance belongs to the content pipeline, not a viewer fix
T-01-SC Tampering npm/pip/cargo installs high mitigate This plan installs nothing — one new composable, one view edit, one vitest file. If an implementation choice would add a dependency, stop: RESEARCH.md's Package Legitimacy Audit must cover it first, with a blocking human checkpoint for any [ASSUMED]/[SUS] entry
</threat_model>
- `cd neode-ui && npx vitest run` — green. - `cd neode-ui && npm run build` — green, and the built bundle carries the new strings. - Per-surface slow-open verdict table recorded in the SUMMARY.

<success_criteria>

  • Paid Files pictures and videos open in the app lightbox; audio and documents are unchanged.
  • The fetch is visibly in progress while it runs and its failures are surfaced, not swallowed.
  • One fetch per click, one owner per object URL.
  • Every phase-2-flagged slow surface has a recorded verdict, and no cached revisit gained a spinner. </success_criteria>
Create `.planning/phases/01-federation-mesh-hardening/01-14-SUMMARY.md` when done, recording the per-surface verdict table, any files added to scope by Task 2, and the URL-ownership decision. Stage by explicit path, commit, and `git push gitea-ai main`.