feat(content): owned-content persistence + Fedimint paid downloads, fmcd caps fix, FIPS warm-path perf

Buyer-side paid downloads now persist: purchases are cached on disk
(content_owned.rs) keyed by (seller onion, content_id), the gallery shows
an "Owned" badge unblurred, and items view/play in-app from the local
cache with no re-payment or reliance on a browser download (which
silently failed on the mobile companion). New RPCs content.owned-list /
content.owned-get. Validated e2e .116<-.198 (paid 100 sats via Fedimint,
166KB jpeg returns, survives restart).

fedimint-clientd manifest: restore the standard container capability set
(CHOWN/DAC_OVERRIDE/FOWNER/SETUID/SETGID) so fmcd's startup chown of an
existing-federation /data succeeds instead of dying EPERM (#7). Confirmed
the orchestrator applies these to the running container.

FIPS perf: tighten the supervisor warm-path keepalive 45s -> 25s so peer
paths stay inside the ~30-60s NAT cold window. Dials now reliably land on
FIPS instead of re-punching and falling back to Tor. Measured to the same
peer: cloud browse 18-22s -> 0.4s; full Fedimint paid download 29s -> 11s
(residual is the seller-side guardian reissue round-trip).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-06-20 18:58:52 -04:00
co-authored by Claude Opus 4.8
parent b0c9bd2a0c
commit db7d424bff
12 changed files with 544 additions and 26 deletions
+31 -2
View File
@@ -110,10 +110,39 @@ function recordError(source: string, err: unknown, info?: string) {
app.config.errorHandler = (err, _instance, info) => recordError('Vue Error', err, info)
// After a frontend deploy the browser's cached index.html still points at the
// OLD hashed chunks (e.g. AppSession-Cq93o4ao.js), which 404 — Vite then throws
// "Failed to fetch dynamically imported module". The fix is to reload once so
// the browser pulls the fresh index + chunk map. Guard with sessionStorage so a
// genuinely-broken chunk can't trap us in a reload loop.
function isStaleChunkError(err: unknown): boolean {
const msg = (err as { message?: string })?.message ?? String(err ?? '')
return /Failed to fetch dynamically imported module|error loading dynamically imported module|Importing a module script failed|ChunkLoadError|dynamically imported module/i.test(msg)
}
function reloadOnceForStaleChunk(err: unknown): boolean {
if (!isStaleChunkError(err)) return false
try {
const KEY = 'archy-chunk-reload-at'
const last = Number(sessionStorage.getItem(KEY) || '0')
// Only reload if we haven't already tried in the last 10s (loop guard).
if (Date.now() - last < 10_000) return false
sessionStorage.setItem(KEY, String(Date.now()))
} catch { /* sessionStorage unavailable — reload anyway, once is better than stuck */ }
// Cache-bust the navigation so the stale index isn't served again.
location.reload()
return true
}
// Vue's errorHandler only catches errors raised synchronously inside Vue's
// lifecycle/reactivity. Async rejections and plain runtime errors (e.g. a JS
// API missing in an older WebView) slip past it, so catch those too.
window.addEventListener('error', (ev) => recordError('window.error', ev.error ?? ev.message))
window.addEventListener('unhandledrejection', (ev) => recordError('unhandledrejection', ev.reason))
window.addEventListener('error', (ev) => {
if (reloadOnceForStaleChunk(ev.error ?? ev.message)) return
recordError('window.error', ev.error ?? ev.message)
})
window.addEventListener('unhandledrejection', (ev) => {
if (reloadOnceForStaleChunk(ev.reason)) return
recordError('unhandledrejection', ev.reason)
})
app.mount('#app')
+17
View File
@@ -413,4 +413,21 @@ router.afterEach((to) => {
}
})
// A route whose lazy-loaded component chunk 404s (stale index after a deploy)
// rejects through router.onError rather than window.unhandledrejection. Reload
// once so the browser fetches the fresh index + chunk map; the sessionStorage
// guard (10s) prevents a reload loop if the chunk is genuinely broken.
router.onError((err) => {
const msg = (err as { message?: string })?.message ?? String(err ?? '')
if (!/Failed to fetch dynamically imported module|error loading dynamically imported module|Importing a module script failed|ChunkLoadError|dynamically imported module/i.test(msg)) {
return
}
try {
const KEY = 'archy-chunk-reload-at'
if (Date.now() - Number(sessionStorage.getItem(KEY) || '0') < 10_000) return
sessionStorage.setItem(KEY, String(Date.now()))
} catch { /* sessionStorage unavailable — reload anyway */ }
location.reload()
})
export default router
+7 -4
View File
@@ -500,10 +500,13 @@ const walletTransactions = ref<WalletTransaction[]>([])
function openInMempool(txHash: string) { router.push({ name: 'app-session', params: { appId: 'mempool' }, query: { path: `/tx/${txHash}` } }) }
async function loadWeb5Status() {
try { const res = await rpcClient.call<{ balance_sats: number; channel_balance_sats: number }>({ method: 'lnd.getinfo', timeout: 5000 }); walletOnchain.value = res.balance_sats || 0; walletLightning.value = res.channel_balance_sats || 0; walletConnected.value = true } catch { walletConnected.value = false; walletOnchain.value = 0; walletLightning.value = 0 }
try { const res = await rpcClient.call<{ balance_sats: number }>({ method: 'wallet.ecash-balance', timeout: 5000 }); walletEcash.value = res.balance_sats ?? 0 } catch { walletEcash.value = 0 }
try { const res = await rpcClient.call<{ balance_sats: number }>({ method: 'wallet.fedimint-balance', timeout: 5000 }); walletFedimint.value = res.balance_sats ?? 0 } catch { walletFedimint.value = 0 }
try { const res = await rpcClient.call<{ transactions: WalletTransaction[]; incoming_pending_count: number }>({ method: 'lnd.gettransactions', timeout: 5000 }); walletTransactions.value = res.transactions || [] } catch { walletTransactions.value = [] }
// A transient RPC timeout must NOT flash the balance to 0 ("wallet says 0 when
// there is a balance"). On failure keep the last-known value — the refs start
// at 0, so only the very first load before any success shows 0.
try { const res = await rpcClient.call<{ balance_sats: number; channel_balance_sats: number }>({ method: 'lnd.getinfo', timeout: 5000 }); walletOnchain.value = res.balance_sats || 0; walletLightning.value = res.channel_balance_sats || 0; walletConnected.value = true } catch { walletConnected.value = false }
try { const res = await rpcClient.call<{ balance_sats: number }>({ method: 'wallet.ecash-balance', timeout: 5000 }); walletEcash.value = res.balance_sats ?? 0 } catch { /* keep last-known balance */ }
try { const res = await rpcClient.call<{ balance_sats: number }>({ method: 'wallet.fedimint-balance', timeout: 5000 }); walletFedimint.value = res.balance_sats ?? 0 } catch { /* keep last-known balance */ }
try { const res = await rpcClient.call<{ transactions: WalletTransaction[]; incoming_pending_count: number }>({ method: 'lnd.gettransactions', timeout: 5000 }); walletTransactions.value = res.transactions || [] } catch { /* keep last-known transactions */ }
}
// System stats
+194 -16
View File
@@ -79,14 +79,14 @@
<div
v-if="isMediaMime(item.mime_type)"
class="relative aspect-video overflow-hidden cursor-pointer group"
@click="isPlayable(item.mime_type) ? playMedia(item) : undefined"
@click="isOwned(item) ? viewOwned(item) : (isPlayable(item.mime_type) ? playMedia(item) : undefined)"
>
<img
v-if="item.mime_type.startsWith('image/') && previewUrls[item.id]"
:src="previewUrls[item.id]"
:alt="item.filename"
class="w-full h-full object-cover"
:style="isPaidItem(item.access) ? 'filter: blur(16px); transform: scale(1.15);' : ''"
:style="(isPaidItem(item.access) && !isOwned(item)) ? 'filter: blur(16px); transform: scale(1.15);' : ''"
/>
<video
v-else-if="item.mime_type.startsWith('video/') && previewUrls[item.id]"
@@ -120,15 +120,22 @@
</svg>
</div>
</div>
<!-- Paid badge (top-right) -->
<div v-if="isPaidItem(item.access)" class="absolute top-2 right-2 flex items-center gap-1.5 px-2 py-1 rounded-lg bg-black/60 backdrop-blur-sm">
<!-- Owned badge (top-right) purchased, unlocked for this buyer -->
<div v-if="isPaidItem(item.access) && isOwned(item)" class="absolute top-2 right-2 flex items-center gap-1.5 px-2 py-1 rounded-lg bg-green-500/20 backdrop-blur-sm">
<svg class="w-4 h-4 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
<span class="text-xs font-medium text-green-400">Owned</span>
</div>
<!-- Paid badge (top-right) not yet purchased -->
<div v-else-if="isPaidItem(item.access)" class="absolute top-2 right-2 flex items-center gap-1.5 px-2 py-1 rounded-lg bg-black/60 backdrop-blur-sm">
<svg class="w-4 h-4 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
<span class="text-xs font-medium text-orange-400">{{ getItemPrice(item.access) }} sats</span>
</div>
<!-- Preview badge for paid playable items -->
<div v-if="isPaidItem(item.access) && isPlayable(item.mime_type)" class="absolute bottom-2 left-2 px-2 py-0.5 rounded bg-black/60 backdrop-blur-sm">
<!-- Preview badge for paid playable items (only before purchase) -->
<div v-if="isPaidItem(item.access) && !isOwned(item) && isPlayable(item.mime_type)" class="absolute bottom-2 left-2 px-2 py-0.5 rounded bg-black/60 backdrop-blur-sm">
<span class="text-xs text-white/70">10% preview</span>
</div>
</div>
@@ -153,9 +160,27 @@
>
{{ accessLabel(item.access) }}
</span>
<!-- Play button for audio/video -->
<!-- View button for owned content (image/video/audio), from cache -->
<button
v-if="isPlayable(item.mime_type)"
v-if="isOwned(item) && isMediaMime(item.mime_type)"
class="glass-button px-3 py-1.5 rounded-lg text-xs font-medium flex items-center gap-1.5"
:disabled="playing === item.id"
@click="viewOwned(item)"
>
<template v-if="playing === item.id">
<div class="w-3 h-3 border-2 border-white/20 border-t-white/80 rounded-full animate-spin"></div>
<span>Opening...</span>
</template>
<template v-else>
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7L8 5z" />
</svg>
<span>View</span>
</template>
</button>
<!-- Preview/Play button pre-purchase only -->
<button
v-else-if="isPlayable(item.mime_type)"
class="glass-button px-3 py-1.5 rounded-lg text-xs font-medium flex items-center gap-1.5"
:disabled="playing === item.id"
@click="playMedia(item)"
@@ -178,9 +203,9 @@
>
<template v-if="downloading === item.id">
<div class="w-3 h-3 border-2 border-white/20 border-t-white/80 rounded-full animate-spin"></div>
<span>{{ isPaidItem(item.access) ? 'Paying...' : 'Loading...' }}</span>
<span>{{ isPaidItem(item.access) && !isOwned(item) ? 'Paying...' : 'Loading...' }}</span>
</template>
<template v-else-if="isPaidItem(item.access)">
<template v-else-if="isPaidItem(item.access) && !isOwned(item)">
<svg class="w-3.5 h-3.5 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
@@ -190,7 +215,7 @@
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
<span>Download</span>
<span>{{ isOwned(item) ? 'Save' : 'Download' }}</span>
</template>
</button>
</div>
@@ -260,6 +285,67 @@
</Transition>
</Teleport>
<!-- Owned-content viewer full file from the local cache (no re-payment) -->
<Teleport to="body">
<Transition name="fade">
<div
v-if="viewerUrl && viewerItem"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/90 backdrop-blur-sm p-4"
@click.self="closeViewer"
>
<div class="relative w-full max-w-4xl mx-4">
<button
class="absolute -top-10 right-0 text-white/60 hover:text-white transition-colors"
@click="closeViewer"
>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<img
v-if="viewerMime.startsWith('image/')"
:src="viewerUrl"
:alt="viewerItem.filename"
class="w-full max-h-[80vh] object-contain rounded-xl bg-black"
/>
<video
v-else-if="viewerMime.startsWith('video/')"
:src="viewerUrl"
class="w-full max-h-[80vh] rounded-xl bg-black"
controls
autoplay
playsinline
/>
<audio
v-else-if="viewerMime.startsWith('audio/')"
:src="viewerUrl"
class="w-full"
controls
autoplay
/>
<div v-else class="glass-card p-8 rounded-xl text-center">
<p class="text-sm text-white/70">This file type can't be previewed. Use Save to download it.</p>
</div>
<div class="mt-3 flex items-center justify-between gap-3">
<div class="min-w-0">
<p class="text-sm font-medium text-white truncate">{{ viewerItem.filename.split('/').pop() }}</p>
<p class="text-xs text-green-400">Owned · unlocked</p>
</div>
<button
class="glass-button px-3 py-1.5 rounded-lg text-xs font-medium flex items-center gap-1.5 shrink-0"
@click="saveOwned(viewerItem)"
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
<span>Save</span>
</button>
</div>
</div>
</div>
</Transition>
</Teleport>
<!-- Payment method picker / Lightning invoice QR (#46) -->
<Teleport to="body">
<Transition name="fade">
@@ -385,7 +471,7 @@
class="flex-1 px-4 py-2.5 rounded-xl text-sm font-semibold text-black bg-green-400 hover:bg-green-300 transition-colors disabled:opacity-50"
:disabled="!ecashPlan.chosen || downloading === payItem.id"
@click="confirmEcashPay"
>{{ downloading === payItem.id ? 'Paying…' : `Confirm · pay with ${ecashPlan.chosen === 'fedimint' ? 'Fedimint' : 'Cashu'}` }}</button>
>{{ downloading === payItem.id ? 'Paying' : 'Pay' }}</button>
</div>
</div>
@@ -537,6 +623,81 @@ const transportPill = computed(() => {
const previewUrls = reactive<Record<string, string>>({})
const audioPlayer = useAudioPlayer()
// ─── Owned (purchased) content ───────────────────────────────────────────
// A paid item the buyer has purchased stays unlocked for them: the backend
// caches the bytes keyed by (seller onion, content_id). We load that set so the
// gallery renders owned items unblurred and opens them in-app from the local
// cache — no re-payment, and no reliance on a browser download (which silently
// fails on the mobile companion, the "paid but never unlocked" report).
const ownedKeys = ref<Set<string>>(new Set())
function ownKey(onion: string, id: string): string { return `${onion}::${id}` }
function isOwned(item: CatalogItem): boolean {
const onion = props.peerId || currentPeer.value?.onion
return !!onion && ownedKeys.value.has(ownKey(onion, item.id))
}
async function loadOwned() {
try {
const res = await rpcClient.call<{ items: { onion: string; content_id: string }[] }>({ method: 'content.owned-list', timeout: 10000 })
const set = new Set<string>()
for (const it of res?.items ?? []) set.add(ownKey(it.onion, it.content_id))
ownedKeys.value = set
} catch { /* keep last-known owned set on a transient failure */ }
}
function base64ToBlob(base64: string, mime: string): Blob {
return new Blob([Uint8Array.from(atob(base64), c => c.charCodeAt(0))], { type: mime })
}
// In-app viewer for owned content (image / video / audio), served from cache.
const viewerItem = ref<CatalogItem | null>(null)
const viewerUrl = ref<string | null>(null)
const viewerMime = ref<string>('')
async function viewOwned(item: CatalogItem) {
const onion = props.peerId || currentPeer.value?.onion
if (!onion) return
playing.value = item.id
purchaseError.value = null
try {
const res = await rpcClient.call<{ data?: string; mime_type?: string; error?: string }>({
method: 'content.owned-get',
params: { onion, content_id: item.id },
timeout: 60000,
})
if (!res?.data) { purchaseError.value = res?.error || 'Could not open your purchased file'; return }
const mime = res.mime_type || item.mime_type
if (viewerUrl.value) URL.revokeObjectURL(viewerUrl.value)
viewerUrl.value = URL.createObjectURL(base64ToBlob(res.data, mime))
viewerMime.value = mime
viewerItem.value = item
} catch (e: unknown) {
purchaseError.value = e instanceof Error ? e.message : 'Could not open your purchased file'
} finally {
playing.value = null
}
}
function closeViewer() {
if (viewerUrl.value) URL.revokeObjectURL(viewerUrl.value)
viewerUrl.value = null
viewerItem.value = null
viewerMime.value = ''
}
// Save the owned file to disk from cache (the optional "downloadable" path).
async function saveOwned(item: CatalogItem) {
const onion = props.peerId || currentPeer.value?.onion
if (!onion) return
try {
const res = await rpcClient.call<{ data?: string; mime_type?: string; error?: string }>({
method: 'content.owned-get',
params: { onion, content_id: item.id },
timeout: 60000,
})
if (res?.data) triggerDownload(res.data, item)
else purchaseError.value = res?.error || 'Could not save your purchased file'
} catch (e: unknown) {
purchaseError.value = e instanceof Error ? e.message : 'Could not save your purchased file'
}
}
// ─── Payment picker (#46) ────────────────────────────────────────────────
// When buying a paid item the user chooses how to pay: their local ecash
// wallet (instant), or a Lightning invoice drawn on the SELLER's node that
@@ -603,7 +764,7 @@ onMounted(async () => {
} catch {
// Continue with just the onion address
}
await loadCatalog()
await Promise.all([loadCatalog(), loadOwned()])
} else {
loading.value = false
}
@@ -737,6 +898,12 @@ async function downloadFile(item: CatalogItem) {
if (!onion) return
purchaseError.value = null
// Already purchased: save from the local cache, no payment.
if (isOwned(item)) {
await saveOwned(item)
return
}
const price = getItemPrice(item.access)
if (price > 0) {
// Let the buyer choose how to pay (local ecash vs external-wallet QR).
@@ -1020,14 +1187,25 @@ async function confirmEcashPay() {
downloading.value = item.id
purchaseError.value = null
try {
const result = await rpcClient.call<{ data?: string; error?: string; ecash_backend?: string }>({
const result = await rpcClient.call<{ data?: string; error?: string; ecash_backend?: string; mime_type?: string }>({
method: 'content.download-peer-paid',
params: { onion, content_id: item.id, price_sats: price, method },
params: { onion, content_id: item.id, price_sats: price, method, filename: item.filename },
timeout: 120000,
})
if (result?.data) {
triggerDownload(result.data, item)
// The purchase is now cached + owned by this node (backend persisted it).
// Mark it owned and open the in-app viewer rather than firing a browser
// download — the latter silently fails on the mobile companion, which is
// why a paid item used to never "unlock". The item stays unlocked going
// forward; the viewer offers a Save button for an explicit download.
ownedKeys.value = new Set(ownedKeys.value).add(ownKey(onion, item.id))
const mime = result.mime_type || item.mime_type
if (viewerUrl.value) URL.revokeObjectURL(viewerUrl.value)
viewerUrl.value = URL.createObjectURL(base64ToBlob(result.data, mime))
viewerMime.value = mime
viewerItem.value = item
closePayModal()
void loadOwned()
} else if (result?.error) {
// Keep the confirm screen open so the user can switch backend and retry.
purchaseError.value = result.error
+1 -1
View File
@@ -329,7 +329,7 @@ async function loadEcashBalance() {
const res = await rpcClient.call<{ balance_sats: number; token_count: number }>({ method: 'wallet.ecash-balance' })
ecashBalance.value = res.balance_sats ?? 0
} catch {
ecashBalance.value = 0
// Keep last-known balance on a transient failure rather than flashing 0.
}
}