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

292 lines
21 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
phase: 01-federation-mesh-hardening
plan: 14
type: execute
wave: 7
depends_on: []
files_modified:
- neode-ui/src/composables/usePaidItemViewer.ts
- neode-ui/src/views/Cloud.vue
- neode-ui/src/composables/__tests__/usePaidItemViewer.test.ts
autonomous: true
requirements: [UIFIX-04, UIFIX-06]
gap_closure: true
must_haves:
truths:
- "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"
prohibitions:
- statement: "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"
category: safety
- statement: "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"
category: privacy
- statement: "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"
category: transparency
artifacts:
- path: neode-ui/src/composables/usePaidItemViewer.ts
provides: "Fetch, decode, route-to-viewer and loading/error state for a purchased item"
contains: "opening"
- path: neode-ui/src/composables/__tests__/usePaidItemViewer.test.ts
provides: "Per-mime routing, loading state, error surfacing and double-click dedup"
min_lines: 60
key_links:
- from: neode-ui/src/views/Cloud.vue
to: neode-ui/src/components/cloud/MediaLightbox.vue
via: "a paid-items lightbox instance fed synthetic FileBrowserItem entries and a resolver that returns the already-fetched object URL"
pattern: "MediaLightbox"
---
<objective>
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.
</objective>
<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/STATE.md
@.planning/phases/01-federation-mesh-hardening/01-UI-SPEC.md
@neode-ui/src/components/cloud/MediaLightbox.vue
</context>
## 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 |
<tasks>
<task type="tracer" tdd="true">
<name>Task 1: End-to-end — a purchased picture opens in the lightbox, with the wait visible</name>
<files>neode-ui/src/composables/usePaidItemViewer.ts, neode-ui/src/views/Cloud.vue, neode-ui/src/composables/__tests__/usePaidItemViewer.test.ts</files>
<read_first>
- `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.
</read_first>
<behavior>
- 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.
</behavior>
<action>
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.
</action>
<verify>
<automated>cd neode-ui &amp;&amp; test -f src/composables/__tests__/usePaidItemViewer.test.ts &amp;&amp; npx vitest run src/composables/__tests__/usePaidItemViewer.test.ts</automated>
</verify>
<acceptance_criteria>
- 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).
</acceptance_criteria>
<done>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.</done>
</task>
<task type="auto">
<name>Task 2: Settle the slow-open inventory — verdict per surface, loader only where genuinely missing</name>
<files>neode-ui/src/composables/__tests__/usePaidItemViewer.test.ts</files>
<read_first>
- `.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.
</read_first>
<action>
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.
</action>
<verify>
<automated>cd neode-ui &amp;&amp; npx vitest run src/composables/__tests__/usePaidItemViewer.test.ts &amp;&amp; npx vitest run &amp;&amp; npm run build</automated>
</verify>
<acceptance_criteria>
- `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.
</acceptance_criteria>
<done>Every flagged surface has an evidence-backed verdict, and the only loaders added are on genuinely slow uncached opens.</done>
</task>
</tasks>
## 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>
<verification>
- `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.
</verification>
<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>
<output>
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`.
</output>