Archipelago — open-source initial import

This commit is contained in:
Archipelago
2026-08-12 10:55:49 +00:00
commit b33b9af85a
1963 changed files with 449763 additions and 0 deletions
@@ -0,0 +1,291 @@
---
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>