--- phase: quick-260729-hj1 plan: 01 type: execute wave: 1 depends_on: [] files_modified: - neode-ui/mock-backend.js - neode-ui/src/views/PeerFiles.vue - demo/content/music/ (2 new Wavlake mp3s) - demo/peer-media/ (Wavlake artwork + replaced photo-*.jpg files) autonomous: true requirements: [A1, A2, A3, A4, A5, A6, A7, A8] user_setup: [] must_haves: truths: - "Searching the Cloud files search for the Wavlake track titles returns them as peer-file results from at least one demo peer (A1, A2)" - "The first Wavlake track is PAID; buying it via the demo ecash flow immediately autoplays it in the bottom GlobalAudioPlayer bar (A3)" - "Cloud -> Paid Files tab shows pre-seeded purchase-history entries (sats paid + date); clicking an audio purchase plays it in the bottom bar (A4)" - "The aggregated Peer Files tab shows each library item on ~1 peer, with only 2-3 items deliberately duplicated across peers (A5)" - "Photo peer files show real photographs as card previews and in the full-screen viewer, not picsum placeholders (A6)" - "In the PeerFiles gallery, clicking a FREE image opens the full-screen image viewer (A8); free audio -> bottom player; free video -> video modal; paid unowned items stay gated behind the pay modal (A7)" - "Real-node build unaffected: all existing unit tests stay green, npm run build succeeds, and the frontend changes degrade gracefully when the real backend omits demo-only fields" artifacts: - "demo/content/music/: two downloaded Wavlake mp3s (real bytes, committed)" - "demo/peer-media/: Wavlake artwork jpg(s) + photo-*.jpg replaced with real photographs" - "neode-ui/mock-backend.js: Wavlake PEER_LIBRARY entries, deduped peerCatalogFor, seeded+persistent owned-content state, real-bytes paid/owned downloads, /api/peer-content streaming route" - "neode-ui/src/views/PeerFiles.vue: click-to-open viewer routing incl. free-image lightbox fix, unified post-payment in-app open/autoplay" key_links: - "content.download-peer-paid must return real bytes + correct mime_type -> confirmEcashPay's audio branch -> audioPlayer.play (this is the A3 autoplay chain; today the mock returns text/plain which breaks it)" - "new /api/peer-content/:onion/:id mock route -> free-audio streaming AND the free-image viewer src (frontend already builds this URL at PeerFiles.vue:1011/1470; it 404s in the demo today)" - "seeded content.owned-list entries must reference onions of the SAME session's demoFederationNodes() output and items actually present in that peer's peerCatalogFor slice, or Owned badges will not line up" - "mock content.owned-list must include session purchases, otherwise loadOwned() (called after every purchase at PeerFiles.vue:1314) wipes the just-bought Owned state" --- Peer-files media batch for the public demo plus two shared-behavior fixes. 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-7ed8b60caec9` returns `data.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 is `https://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-c017945465c4` returns title "Michael Michael Saylor" (single) by Zazawowow with one track id `ba80e385-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-peer` read `entry.disk` with `fsSync.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/*.mp3` and `demo/peer-media/*.jpg` are 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_LIBRARY` at line ~1256: films/series/books paid (no disk bytes), music/photos/ docs free with `disk:` pointing at committed files under `demo/content/` and `demo/peer-media/` (helpers `PEER_MEDIA`/`PEER_CONTENT` at lines 1254-1255). - `peerCatalogFor(onion)` line ~1321: hash-based slice; the `((seed ^ (i * 2654435761)) >>> 0) % 12 < 3` clause 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): serves `entry.preview` or the image's `disk`. - `content.download-peer` (line ~2372): serves `entry.disk` bytes, 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/:id` route — 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 to `undefined` = 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), then `void loadOwned()` (line 1314). - `payWithLightning` (line 1364), `pollInvoice` (line 1417), `pollOnchain` (line 1197): on success these call `triggerDownload` (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 uses `content.preview-peer` bytes ("10% preview"). NOTE: for a PAID AUDIO item the pre-purchase Preview button plays whatever `content.preview-peer` returns — 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) lists `content.owned-list` items (`PaidItem { onion, content_id, filename, mime_type, size_bytes, paid_sats, purchased_at }`) and `viewPaidItem` (line ~470) plays audio in the bottom bar — this is the A4 purchase-history surface, already built; it only needs seeded data. Cloud search (`runSearch` line ~880) filters the aggregated `peerFiles` by filename — Wavlake items are searchable as soon as they appear in any peer's catalog slice. - `GlobalAudioPlayer` is mounted in `App.vue` (line 48); `useAudioPlayer` is a global singleton — nothing to change there. - Tests: `src/views/__tests__/PeerFilesRefresh.test.ts` mounts 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 `), never `git 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. Task 1: Demo dataset — Wavlake tracks, real photos, dedupe, purchases, real paid bytes, streaming route neode-ui/mock-backend.js, demo/content/music/ (2 new mp3s), demo/peer-media/ (artwork + replaced photo-*.jpg) All changes in this task are demo/mock-only (mock-backend.js is not part of the real-node build). 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', ), 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/). cd neode-ui && node --check mock-backend.js && node mock-backend.js & sleep 2; then RPC-probe localhost:5959: (a) content.browse-peer for each federation onion — every PEER_LIBRARY id appears on >=1 peer, total duplicate ids across all peers <= 3, both Wavlake ids present; (b) content.owned-list returns >=2 seeded items with paid_sats and purchased_at; (c) content.download-peer-paid for the paid Wavlake id returns mime_type audio/mpeg with data length > 1MB base64; (d) curl -H "Range: bytes=0-0" /api/peer-content/{onion}/{free-wavlake-id} returns 206; (e) file demo/peer-media/photo-*.jpg all report JPEG; then kill the mock. (If /rpc/v1 requires a session, log in first with the mock demo password password123.) Both Wavlake tracks in the catalog with real committed bytes + real API metadata; first is paid and delivers real audio/mpeg bytes on purchase; owned-list seeded and purchase-persistent; catalog duplication reduced to <=3 intentional items; photos are real JPEGs; /api/peer-content serves Range requests. Committed. Task 2: Shared viewer routing — free-image lightbox fix, click-to-open, unified post-payment open neode-ui/src/views/PeerFiles.vue These changes ship in BOTH the real-node build and the demo — no IS_DEMO gating, and every path must degrade gracefully against the real backend. 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). cd neode-ui && npx vitest run src/views/__tests__/PeerFilesRefresh.test.ts src/composables/__tests__/useAudioPlayer.test.ts Clicking a free image in PeerFiles opens the full-screen viewer (Teleport-to-body, backdrop preserved); free audio -> bottom bar; free video -> video modal; paid unowned image click opens the pay modal and never reveals the image; all four payment-success paths open purchased content in-app with audio autoplaying in the bottom bar. Targeted tests green. Committed. Task 3: Full test suite, production build, bundle verification, demo smoke (no new files — verification only; fixes belong to the task that broke them) 1. Run the full unit suite: cd neode-ui && npx vitest run — all tests (currently 195) must pass. Fix any regression in the file that caused it, amending or adding a fixup commit to the responsible task's change. 2. Production build per CLAUDE.md: cd neode-ui && npm run build (vue-tsc must pass; output lands in web/dist/neode-ui). Grep the built bundle for the Task 2 sentinel string to prove the build did not silently no-op: grep -rl "Free · shared by peer" ../web/dist/neode-ui/assets/ must match at least one js file. 3. End-to-end demo smoke against the mock: start node mock-backend.js plus the dev frontend (or vite preview against the built dist) and exercise via curl/RPC: search corpus contains the Wavlake titles (browse-peer aggregation), paid purchase of the Wavlake track deducts ecash and returns audio/mpeg, owned-list grows by the purchase, /api/peer-content serves the free track with 206. Confirm no occurrence of 146.59.87.168 in mock-backend.js additions or demo-served data: grep -rn "146.59.87.168" neode-ui/mock-backend.js demo/ must be empty. 4. Confirm git hygiene: git status shows nothing staged under indeedhub/; all work is committed across the task commits (code only — the summary doc is the orchestrator's commit). Leave deploy/push to the orchestrator. 5. Write into the task summary a post-deploy live checklist for http://146.59.87.168:2100 (orchestrator deploys): search finds the Wavlake tracks; buy the paid track with ecash -> bottom bar autoplays; Paid Files tab shows seeded purchases; peer catalog shows <=3 duplicated files; photos are real and open in the lightbox on click; free song click plays in bottom bar. cd neode-ui && npx vitest run && npm run build && grep -rl "Free · shared by peer" ../web/dist/neode-ui/assets/ | head -1 && ! grep -rn "146.59.87.168" mock-backend.js ../demo/ Full suite green, production build succeeds, bundle grep proves the new UI string shipped, demo smoke passes, no IP leak, clean git state with per-task commits. ## 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 | - `cd neode-ui && npx vitest run` — full suite green (195 tests baseline). - `cd neode-ui && npm run build` — vue-tsc + vite build succeed; bundle grep for "Free · shared by peer" hits. - Mock RPC smoke: Wavlake items searchable via browse-peer aggregation; paid purchase returns audio/mpeg real bytes and appends to owned-list; duplicate ids across all 12 peers <= 3; /api/peer-content answers 206 to a Range probe; owned-list seeded with dated purchases. - `git log --oneline` shows one focused commit per task; nothing staged under indeedhub/. - 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. On completion the executor reports per-task commit hashes and the post-deploy live verification checklist for http://146.59.87.168:2100 (deployment is the orchestrator's job; docs/summary committed by the orchestrator).