26 KiB
phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, user_setup, must_haves
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | user_setup | must_haves | |||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| quick-260729-hj1 | 01 | execute | 1 |
|
true |
|
|
Demo/mock only (A1-A6): add two real Wavlake tracks (metadata + bytes fetched at execution time), make the first one PAID with a working pay -> autoplay-in-bottom-bar flow, seed visible purchase history, dedupe the peer catalog to ~2-3 intentional duplicates, and replace picsum placeholder photos with real photographs.
Both builds (A7, A8): clicking a peer file opens the appropriate viewer (photos ->
full-screen image viewer, audio -> bottom music player, video -> video modal), and
fix the confirmed bug that FREE images are a click no-op in the PeerFiles gallery
(PeerFiles.vue line 91 ternary falls through to undefined for non-playable free items).
Purpose: the peer-files demo is a flagship "buy content over the mesh" showcase; today paid purchases unlock a text placeholder, free images don't open, free streaming 404s, and the catalog is visibly duplicate-heavy. Output: updated mock-backend.js + committed demo media assets + PeerFiles.vue behavior fixes, tests green, built bundle verified.
Verified recon (do not re-derive; spot-check only)
Wavlake API (probed 2026-07-29, works from this network):
- Track 1 (user link
wavlake.com/track/3504d80b-...):https://catalog.wavlake.com/v1/tracks/3504d80b-b4bf-4196-923b-7ed8b60caec9returnsdata.title = "WEBFIVEFOURTHREETWOONE",data.artist = "Zazawowow",data.albumTitle = "WEBFIVE",data.duration = 249,data.artworkUrl = https://d12wklypp119aj.cloudfront.net/image/ed6c75e5-e469-4f52-b073-a18b237dadae.jpg, and a stream URL whose direct CDN form ishttps://d12wklypp119aj.cloudfront.net/track/3504d80b-b4bf-4196-923b-7ed8b60caec9.mp3(HTTP 200,content-type: audio/mpeg,content-length: 6041903, accept-ranges, NO signature/expiry params — stable). - Album (user link
wavlake.com/album/9be3cdce-...):https://catalog.wavlake.com/v1/albums/9be3cdce-015e-4e7e-8ffe-c017945465c4returns title "Michael Michael Saylor" (single) by Zazawowow with one track idba80e385-62a5-4309-ac1e-bb0493b8539f. Fetch that id from the tracks endpoint for its metadata; its mp3 follows the same CDN pattern (https://d12wklypp119aj.cloudfront.net/track/ba80e385-62a5-4309-ac1e-bb0493b8539f.mp3). - Hotlink-vs-download criterion (decided): DOWNLOAD the mp3s + artwork into the repo.
The CDN URLs are stable, but the mock serves content as base64 bytes / disk files
(
content.download-peer,content.preview-peerreadentry.diskwithfsSync.readFileSync), so committed local files are the architecturally consistent choice and remove any CORS/expiry/offline risk. This matches the existing pattern —demo/content/music/*.mp3anddemo/peer-media/*.jpgare already committed real files. External wavlake/CDN URLs would be allowed under the demo IP-leak rule (only 146.59.87.168 must never appear), but are simply not needed.
Mock backend (neode-ui/mock-backend.js, port 5959, RPC at /rpc/v1):
PEER_LIBRARYat line ~1256: films/series/books paid (no disk bytes), music/photos/ docs free withdisk:pointing at committed files underdemo/content/anddemo/peer-media/(helpersPEER_MEDIA/PEER_CONTENTat lines 1254-1255).peerCatalogFor(onion)line ~1321: hash-based slice; the((seed ^ (i * 2654435761)) >>> 0) % 12 < 3clause puts each item on ~25% of the 12 peers (demoFederationNodes()line ~1339, onions are RANDOM per session) — this is the heavy-duplication source (A5).content.preview-peer(line ~2358): servesentry.previewor the image'sdisk.content.download-peer(line ~2372): servesentry.diskbytes, else text placeholder.content.owned-list(line ~2402): returns{ items: [] }— no purchase history, and it CLOBBERS just-bought state (see key_links).content.owned-get/content.download-peer-paid/-invoice/-onchain(lines ~2405-2425): all return a TEXT PLACEHOLDER (mime_type: text/plain) — this is why paid autoplay (A3) cannot work today. Ecash rail deduction logic here must be kept.- There is NO
/api/peer-content/:onion/:idroute — the frontend's free-item stream URL (PeerFiles.vue lines 1011 and 1470) 404s in the demo.
Frontend (neode-ui/src/views/PeerFiles.vue, 1616 lines):
- Card click (line 91):
isOwned ? viewOwned : (isPlayable ? playMedia : undefined)— free images fall toundefined= the A8 bug.isPlayable(line 963) is video/audio only. viewOwned(line 702): audio ->audioPlayer.play(bottom bar), image/video -> the Teleport-to-body "Owned-content viewer" modal (template line 310, footer hardcodes "Owned · unlocked" at line 354).confirmEcashPay(line 1277): on success marks owned, audio ->audioPlayer.play(A3 autoplay ALREADY implemented here — only the mock's text/plain response breaks it), thenvoid loadOwned()(line 1314).payWithLightning(line 1364),pollInvoice(line 1417),pollOnchain(line 1197): on success these calltriggerDownload(browser download) instead of the in-app open/autoplay path — inconsistent with confirmEcashPay.playMedia(line 1460): free audio ->audioPlayer.play(streamUrl), free video -> video modal via the same/api/peer-content/stream URL; paid usescontent.preview-peerbytes ("10% preview"). NOTE: for a PAID AUDIO item the pre-purchase Preview button plays whatevercontent.preview-peerreturns — so for the paid Wavlake track the mock MUST return audio bytes there, not artwork.- Preview thumbnails (watcher line 872): fetched ONLY for image/video mimes; audio cards
show a waveform icon. The real backend's
content.preview-peer(core/archipelago/src/api/rpc/content.rs:1113) proxies the seller's/content/{id}/preview— for audio that is audio bytes, so do NOT extend the thumbnail watcher to audio (it would fetch audio blobs as "thumbnails" on real nodes). Cloud.vue: Paid Files tab (line ~155) listscontent.owned-listitems (PaidItem { onion, content_id, filename, mime_type, size_bytes, paid_sats, purchased_at }) andviewPaidItem(line ~470) plays audio in the bottom bar — this is the A4 purchase-history surface, already built; it only needs seeded data. Cloud search (runSearchline ~880) filters the aggregatedpeerFilesby filename — Wavlake items are searchable as soon as they appear in any peer's catalog slice.GlobalAudioPlayeris mounted inApp.vue(line 48);useAudioPlayeris a global singleton — nothing to change there.- Tests:
src/views/__tests__/PeerFilesRefresh.test.tsmounts PeerFiles.vue — keep green.
Project rules that bind this work:
- Modals/lightboxes must Teleport to body with full-screen backdrop (the existing owned-content viewer already complies — reuse it).
- Do not add new UI entry points (no new tabs/cards/nav); only change behavior of existing elements.
- NEVER stage/commit anything under
indeedhub/(git submodule). Stage explicitly by path (git add <paths>), nevergit add -A. - Never expose 146.59.87.168 in demo-served content.
- Commit each task when it works, message trailer
Co-Authored-By: Claude .... Executor commits code only; docs/summary are committed by the orchestrator.
1. Fetch Wavlake assets at execution time (A1, A2). Use curl against the verified
endpoints in context: resolve track 3504d80b-b4bf-4196-923b-7ed8b60caec9 and the
album 9be3cdce-015e-4e7e-8ffe-c017945465c4's single track
ba80e385-62a5-4309-ac1e-bb0493b8539f via catalog.wavlake.com/v1/tracks/{id}.
Parse real title/artist/albumTitle/duration/artworkUrl from the JSON (jq or node -e)
— do NOT hardcode metadata from this plan except the ids; the API is the source of
truth. Download each track's CDN mp3 into demo/content/music/ (filename derived from
real title, e.g. "Zazawowow - WEBFIVEFOURTHREETWOONE.mp3") and each artworkUrl jpg
into demo/peer-media/. Sanity-check downloads with file(1): mp3s must be MPEG audio
(~6MB expected for track 1), artwork must be JPEG. Abort and report if the API shape
changed.
2. Add both tracks to PEER_LIBRARY (A1-A3). mime_type audio/mpeg, real size_bytes
(actual file size), disk: PEER_CONTENT('music', <filename>), description carrying
real artist/album/duration metadata (this makes artist searchable too). First track
(WEBFIVEFOURTHREETWOONE): access paid with a price the demo ecash balance easily
covers (e.g. 21 sats). Second track: access 'free'. For the PAID track, give it a
preview behavior consistent with playMedia: content.preview-peer for audio entries
must return AUDIO bytes (read entry.disk; serving the full mp3 is acceptable for the
demo, or the first ~10% byte-slice to match the "10% preview" badge — mp3 frames
tolerate truncation), NOT the artwork jpg — see the recon note on playMedia's paid
path. Keep image entries' preview behavior unchanged.
3. Dedupe peerCatalogFor (A5). Replace the probabilistic
`((seed ^ (i * 2654435761)) >>> 0) % 12 < 3` inclusion with a deterministic
assignment: each PEER_LIBRARY item lives on exactly one peer (e.g. index-based
slot), plus a small explicit POPULAR list of 2-3 item ids that additionally appear
on 1-2 more peers (a little duplication is realistic). Keep the function signature
and per-session determinism (same onion -> same slice within a session). Ensure both
Wavlake tracks land on at least one trusted peer's slice so Cloud search finds them.
4. Real photos (A6). Replace all ten demo/peer-media/photo-*.jpg picsum placeholders
with real photographs downloaded at execution time from a stable free-license source
(Wikimedia Commons Special:FilePath URLs with a width parameter, e.g. ?width=1200,
are reliable and hotlink-free once committed). Pick images that match each entry's
existing description (aurora over fjord, mountain lake, neon city rain, desert
dunes, forest mist, ocean cliff, northern road, autumn valley, harbor dawn, alpine
ridge) or update the descriptions (including the "sourced via picsum.photos"
credit text and the stale PEER_LIBRARY header comment) to match reality. Keep the
photo-*.jpg filenames so PEER_MEDIA references don't change; update each entry's
size_bytes to the new actual file size. Verify each file with file(1) is a real
JPEG of reasonable resolution (>= ~1000px wide).
5. Owned/purchase state (A3, A4). Introduce a mock owned-content store (e.g.
mockState.ownedContent array of { onion, content_id, filename, mime_type,
size_bytes, paid_sats, purchased_at }). Lazily seed it on first access with 2-4
plausible past purchases dated days-to-weeks ago, referencing onions from the
CURRENT session's demoFederationNodes() output and content ids actually present in
that peer's peerCatalogFor slice (onions are random per session — compute, don't
hardcode). At least one seeded purchase must be an audio item backed by real disk
bytes so the Paid Files tab click plays music. content.owned-list returns this
store (all fields of Cloud.vue's PaidItem interface). Every successful purchase
path (content.download-peer-paid, and the -invoice/-onchain and onchain/invoice
status flows' download calls) appends an entry with the real price and
purchased_at=now, so the Owned badge survives the post-purchase loadOwned()
refresh and purchases show up in the Paid Files tab.
6. Real bytes for paid/owned downloads (A3). Rework the shared
content.owned-get / content.download-peer-paid / -invoice / -onchain case: look up
the PEER_LIBRARY entry by content_id; when entry.disk exists return the real file
bytes with mime_type entry.mime_type; keep the text placeholder only as fallback
for entries without disk bytes (the fictional 2GB films). Preserve the existing
ecash rail-deduction logic exactly. This makes buying the Wavlake track deliver a
real mp3 with mime_type audio/mpeg, which is what confirmEcashPay's existing audio
branch needs to autoplay in the bottom bar.
7. Streaming route. Add an Express GET route /api/peer-content/:onion/:content_id
to mock-backend.js that resolves the PEER_LIBRARY entry and serves entry.disk via
res.sendFile (Express handles Range/206 automatically — the frontend probes with
Range: bytes=0-0), 404 JSON { error } otherwise. This unbreaks the demo's existing
free-audio/video streaming and is required by Task 2's free-image viewer. Verify
the demo/dev proxy actually forwards /api/* to the mock (check vite.config.ts /
vite.preview.config.mts proxy config and the demo docker nginx config if present);
if /api is not proxied in dev, register the route on whatever path prefix reaches
the mock and keep the frontend URL unchanged (the frontend path is fixed — it must
work on real nodes too, where nginx proxies /api to the daemon).
Commit this task on its own (stage mock-backend.js + the demo/ media files
explicitly by path; git add demo/content/music demo/peer-media is fine, never
git add -A; nothing under indeedhub/).
1. Fix the A8 bug + A7 click routing at the card click handler (line 91). Replace
the ternary with a single openItem(item) dispatcher: owned -> viewOwned (existing);
paid-unowned playable -> playMedia (existing 10% preview, keep); paid-unowned
NON-playable (images, blurred) -> openPayModal(item) so the click gets the
appropriate "viewer" for locked content while preserving the paid gating (the
full image is never fetched or revealed pre-purchase — only the blurred thumbnail);
FREE image -> open the existing Teleport-to-body owned-content viewer modal
(template line 310) as a lightbox: set viewerItem/viewerMime and point viewerUrl
at the free stream URL /api/peer-content/{onion}/{id} directly (an img src streams
it; no base64 round-trip; works on real nodes via the Range proxy and in the demo
via Task 1's new route). Free audio/video already route through playMedia — keep.
Guard closeViewer's URL.revokeObjectURL so it only revokes blob: URLs.
2. Generalize the viewer footer (line 354): the hardcoded "Owned · unlocked" green
caption must only show for owned items; for free items show a neutral caption
(exact string "Free · shared by peer" — also used as the bundle-grep sentinel), and
make the footer Save button use the free download path for free items (streamDownload)
instead of content.owned-get.
3. Unify post-payment success handling (A3-adjacent, both builds). payWithLightning
(line 1364), pollInvoice (line 1417) and pollOnchain (line 1197) currently
triggerDownload on success; align them with confirmEcashPay (line 1277): mark the
item owned in ownedKeys, refresh loadOwned, and open in-app — audio ->
audioPlayer.play (bottom-bar autoplay), image/video -> the viewer modal — using the
mime_type from the download response with item.mime_type as fallback. Extract the
shared logic (e.g. an openPurchased(item, data, mime) helper) rather than
duplicating it four times. Keep triggerDownload available via the viewer's Save
button. Do not touch the payment/polling logic itself.
4. Do NOT extend the preview-thumbnail watcher (line 872) to audio — on real nodes
content.preview-peer returns audio bytes for audio items (see recon), which must
not be used as an img src. Audio cards keep the waveform icon.
5. Sanity-check the other previews-grid views for the same A8 class of bug:
CloudFolder.vue / Cloud.vue My Files use FileCard @preview -> handlePreview ->
MediaLightbox and should already open free images; verify by reading the handler
chain (no change expected — do not modify them if they work).
6. Keep src/views/__tests__/PeerFilesRefresh.test.ts green; if the click-dispatch
refactor is cheaply testable, extend that test file with a case asserting a free
image click sets viewerUrl (do not build new test infrastructure).
Commit this task separately (stage neode-ui/src paths explicitly).
<threat_model>
Trust Boundaries
| Boundary | Description |
|---|---|
| executor -> catalog.wavlake.com / CloudFront / Wikimedia | External bytes fetched at execution time get committed into the repo and served by the demo |
| demo visitor -> mock backend | Untrusted public visitors hit the new /api/peer-content route |
STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|---|---|---|---|---|---|
| T-hj1-01 | Tampering | Downloaded mp3/jpg assets | medium | mitigate | Verify every download with file(1) magic-type + plausible size before committing; fetch only from the verified catalog.wavlake.com/CloudFront/Wikimedia URLs over https |
| T-hj1-02 | Information Disclosure | Demo-served data | high | mitigate | Task 3 gate: grep for 146.59.87.168 across mock-backend.js and demo/ must be empty |
| T-hj1-03 | Tampering (path traversal) | /api/peer-content route | medium | mitigate | Route resolves content_id strictly against PEER_LIBRARY entries (whitelist lookup, never a filesystem path built from request input) |
| T-hj1-04 | Elevation | Paid-content gating in PeerFiles.vue | medium | mitigate | Paid-unowned image click opens the pay modal only; the free-image viewer path is reachable solely when access !== paid; no pre-purchase full-content fetch is added |
| </threat_model> |
<success_criteria>
- A1/A2: both Wavlake tracks (real API metadata, real committed bytes) searchable in the peer-files search.
- A3: first Wavlake track is paid; demo ecash purchase autoplays it in the bottom GlobalAudioPlayer bar.
- A4: Cloud -> Paid Files shows seeded purchase history; audio purchases play on click; new purchases persist in the list.
- A5: at most 2-3 deliberately duplicated files across the aggregated peer catalog.
- A6: all photo peer files are real photographs matching their descriptions.
- A7/A8 (both builds): free image click opens the full-screen viewer, free audio -> bottom player, free video -> video modal, paid gating preserved; all payment paths open purchased media in-app.
- Non-demo build safe: tests green, build clean, frontend degrades gracefully without demo-only mock data. </success_criteria>