feat: Cashu marketplace for paid episodes, resale, and cross-instance discovery

Paid episodes:
- server/services/cashu.ts: self-custodied Cashu wallet against a configured
  mint (NUT-04 mint quote -> bolt11 invoice -> mint/store proofs -> LNURL-pay
  payout on withdraw). Buyers pay a plain Lightning invoice, no Cashu wallet
  needed on their end.
- routes/marketplace.ts: purchase/confirm flow, download-url paywall gate,
  reseller certification/revocation, earnings ledger + withdraw.
- services/marketplace.ts: producer + certified-reseller source resolution,
  with a naive per-seller sales-count reputation signal.

Discovery:
- services/rss.ts: <podsteadr:source> RSS tag on priced episodes (producer +
  resellers, price, sales count, url) so pricing/sources are discoverable
  straight from the feed, not just a separate API call. Locked episodes point
  their <enclosure> at an info page instead of the raw file.
- routes/feeds.ts: /catalog.opml lists every podcast this instance hosts, for
  peer podsteadr servers or any OPML-aware crawler to discover without a
  central directory.

Frontend: episode wizard price/reseller controls, sources display, earnings
dashboard.

Also fixes CORS and a container-image reference:
- app.ts: register @fastify/cors, open on the catalog/feed/sources endpoints
  (already deliberately public/crawlable) and on purchase/confirm (already
  accept stateless NIP-98 header auth for exactly this case). Credentials
  stay off, so the session cookie never crosses origins — cookie-authed
  admin routes stay same-origin-only. Needed so external clients like
  podsteadr-player can browse/buy/play from a different origin.
- docker-compose.yml: fully qualify the mediamtx image reference
  (docker.io/bluenviron/mediamtx:1.19.2) — some Podman hosts have no
  unqualified-search registry configured and fail to resolve short names.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-29 12:43:46 +00:00
co-authored by Claude Sonnet 5
parent 2d61abc40c
commit e92ef7fef9
27 changed files with 1825 additions and 18 deletions
+1
View File
@@ -20,6 +20,7 @@ async function logout() {
</RouterLink>
<nav v-if="auth.pubkey" class="flex items-center gap-4 text-sm">
<RouterLink to="/" class="hover:text-puddle-600">Home</RouterLink>
<RouterLink to="/earnings" class="hover:text-puddle-600">Earnings</RouterLink>
<RouterLink to="/settings" class="hover:text-puddle-600">Settings</RouterLink>
<button class="btn-secondary !px-3 !py-1" @click="logout">
<span class="max-w-[8rem] truncate font-mono text-xs">{{ auth.displayName || auth.pubkey.slice(0, 8) + '…' }}</span>
+182
View File
@@ -0,0 +1,182 @@
<script setup lang="ts">
// Lists the producer + any certified resellers for a priced episode, times each URL from the
// listener's own browser (server-side latency would be meaningless — it doesn't know the
// listener's network path), and drives the buy → invoice → confirm flow.
import { computed, onMounted, ref } from 'vue';
import { api, ApiError } from '../lib/api';
import CopyField from './CopyField.vue';
const props = defineProps<{ podcastId: string; episodeId: string }>();
const emit = defineEmits<{ purchased: [] }>();
interface Source {
type: 'producer' | 'reseller';
pubkey: string;
price_sats: number;
url: string;
sales_count?: number;
}
interface SourcesResponse {
producer: Source;
resellers: Source[];
}
interface Row extends Source {
latencyMs: number | null;
hostKind: 'FQDN' | 'IPv4';
}
const sortKey = ref<'price_sats' | 'sales_count' | 'latencyMs'>('price_sats');
const rows = ref<Row[]>([]);
const loading = ref(true);
const error = ref('');
const buyingPubkey = ref<string | null>(null);
const invoice = ref('');
const quoteId = ref('');
const confirming = ref(false);
const confirmError = ref('');
const purchased = ref(false);
const sortedRows = computed(() =>
[...rows.value].sort((a, b) => {
if (sortKey.value === 'latencyMs') return (a.latencyMs ?? Infinity) - (b.latencyMs ?? Infinity);
if (sortKey.value === 'sales_count') return (b.sales_count ?? 0) - (a.sales_count ?? 0);
return a.price_sats - b.price_sats;
}),
);
function isIPv4(hostname: string): boolean {
return /^(\d{1,3}\.){3}\d{1,3}$/.test(hostname);
}
async function pingUrl(url: string): Promise<number | null> {
const start = performance.now();
try {
// no-cors: we can't read the response, but the promise still resolves once headers
// arrive, which is enough to measure round-trip time to a cross-origin mirror.
await fetch(url, { method: 'HEAD', mode: 'no-cors', cache: 'no-store' });
return Math.round(performance.now() - start);
} catch {
return null;
}
}
async function load() {
loading.value = true;
error.value = '';
try {
const res = await api.get<SourcesResponse>(`/api/podcasts/${props.podcastId}/episodes/${props.episodeId}/sources`);
const all: Source[] = [res.producer, ...res.resellers];
rows.value = all.map((s) => ({
...s,
latencyMs: null,
hostKind: isIPv4(new URL(s.url).hostname) ? 'IPv4' : 'FQDN',
}));
// Ping in parallel and fill in as they resolve, rather than blocking the whole list.
all.forEach(async (s, i) => {
const ms = await pingUrl(s.url);
const row = rows.value.find((r) => r.pubkey === s.pubkey && r.type === s.type);
if (row) row.latencyMs = ms;
void i;
});
} catch (err) {
error.value = (err as Error).message;
} finally {
loading.value = false;
}
}
onMounted(load);
async function buy(row: Row) {
buyingPubkey.value = row.pubkey;
confirmError.value = '';
invoice.value = '';
try {
const res = await api.post<{ quoteId: string; invoice: string; amountSats: number }>(
`/api/podcasts/${props.podcastId}/episodes/${props.episodeId}/purchase`,
{ source: row.type === 'producer' ? 'producer' : row.pubkey },
);
quoteId.value = res.quoteId;
invoice.value = res.invoice;
} catch (err) {
confirmError.value = (err as Error).message;
buyingPubkey.value = null;
}
}
async function checkPaid() {
confirming.value = true;
confirmError.value = '';
try {
await api.post(`/api/podcasts/${props.podcastId}/episodes/${props.episodeId}/purchase/${quoteId.value}/confirm`);
purchased.value = true;
emit('purchased');
} catch (err) {
if (err instanceof ApiError && err.status === 402) {
confirmError.value = "Not paid yet — pay the invoice above, then try again.";
} else {
confirmError.value = (err as Error).message;
}
} finally {
confirming.value = false;
}
}
function cancelBuy() {
buyingPubkey.value = null;
invoice.value = '';
quoteId.value = '';
confirmError.value = '';
}
</script>
<template>
<div class="space-y-3">
<p v-if="loading" class="text-sm text-slate-500">Loading sources</p>
<p v-else-if="error" class="rounded-lg bg-red-50 p-3 text-sm text-red-700">{{ error }}</p>
<template v-else>
<div class="flex gap-3 text-xs text-slate-500">
<span>Sort by:</span>
<button class="underline" :class="{ 'font-semibold text-slate-800': sortKey === 'price_sats' }" @click="sortKey = 'price_sats'">cheapest</button>
<button class="underline" :class="{ 'font-semibold text-slate-800': sortKey === 'sales_count' }" @click="sortKey = 'sales_count'">reputation</button>
<button class="underline" :class="{ 'font-semibold text-slate-800': sortKey === 'latencyMs' }" @click="sortKey = 'latencyMs'">fastest</button>
</div>
<ul class="space-y-2">
<li v-for="row in sortedRows" :key="`${row.type}-${row.pubkey}`" class="card !p-3">
<div class="flex items-center justify-between gap-3">
<div class="min-w-0">
<p class="font-medium">
{{ row.type === 'producer' ? 'Producer' : 'Reseller' }}
<span class="font-mono text-xs text-slate-400">{{ row.pubkey.slice(0, 12) }}</span>
</p>
<p class="text-xs text-slate-500">
{{ row.price_sats }} sats
<span v-if="row.type === 'reseller'">· {{ row.sales_count }} sales</span>
· {{ row.hostKind }}
· <span v-if="row.latencyMs != null">{{ row.latencyMs }}ms</span><span v-else>unreachable</span>
</p>
</div>
<button class="btn-primary shrink-0" :disabled="buyingPubkey === row.pubkey" @click="buy(row)">
Buy
</button>
</div>
<div v-if="buyingPubkey === row.pubkey" class="mt-3 space-y-2 border-t border-slate-100 pt-3">
<template v-if="!purchased">
<CopyField label="Pay this invoice with any Lightning wallet" :value="invoice" />
<p v-if="confirmError" class="text-sm text-red-700">{{ confirmError }}</p>
<div class="flex gap-2">
<button class="btn-secondary" @click="cancelBuy">Cancel</button>
<button class="btn-primary" :disabled="confirming" @click="checkPaid">
{{ confirming ? 'Checking' : "I've paid unlock" }}
</button>
</div>
</template>
<p v-else class="text-sm font-medium text-green-700">🎉 Purchased you now have access.</p>
</div>
</li>
</ul>
</template>
</div>
</template>
+2
View File
@@ -9,6 +9,8 @@ export const router = createRouter({
{ path: '/upload', component: () => import('./views/wizard/EpisodeWizard.vue') },
{ path: '/live', component: () => import('./views/wizard/LiveWizard.vue') },
{ path: '/streams/:id', component: () => import('./views/StreamDashboard.vue') },
{ path: '/podcasts/:id/episodes/:eid', component: () => import('./views/EpisodeDetailView.vue') },
{ path: '/earnings', component: () => import('./views/EarningsView.vue') },
{ path: '/settings', component: () => import('./views/SettingsView.vue') },
],
});
+86
View File
@@ -0,0 +1,86 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { api } from '../lib/api';
interface Earning {
id: string;
purchase_id: string;
amount_sats: number;
withdrawn_at: number | null;
}
const unwithdrawnSats = ref(0);
const earnings = ref<Earning[]>([]);
const loading = ref(true);
const lud16 = ref('');
const withdrawing = ref(false);
const error = ref('');
const result = ref<{ paid_sats: number } | null>(null);
async function load() {
loading.value = true;
const res = await api.get<{ unwithdrawn_sats: number; earnings: Earning[] }>('/api/earnings');
unwithdrawnSats.value = res.unwithdrawn_sats;
earnings.value = res.earnings;
loading.value = false;
}
onMounted(load);
async function withdraw() {
error.value = '';
result.value = null;
withdrawing.value = true;
try {
const res = await api.post<{ paid_sats: number; preimage: string | null }>('/api/earnings/withdraw', {
lud16: lud16.value || undefined,
});
result.value = res;
await load();
} catch (err) {
error.value = (err as Error).message;
} finally {
withdrawing.value = false;
}
}
</script>
<template>
<div class="mx-auto max-w-2xl space-y-6">
<h1 class="text-2xl font-bold">Earnings</h1>
<p class="text-sm text-slate-500">
Sats from selling episodes directly, plus your producer cut whenever a certified
reseller resells one of your episodes.
</p>
<div v-if="loading" class="text-sm text-slate-500">Loading</div>
<template v-else>
<div class="card space-y-4">
<p class="text-3xl font-bold">{{ unwithdrawnSats }} <span class="text-base font-normal text-slate-500">sats available</span></p>
<div v-if="unwithdrawnSats > 0" class="space-y-2">
<label class="label" for="withdraw-lud16">Lightning address to withdraw to</label>
<input id="withdraw-lud16" v-model="lud16" class="input" placeholder="you@getalby.com" pattern="[\w.+-]+@[\w.-]+" />
<p v-if="error" class="text-sm text-red-700">{{ error }}</p>
<p v-if="result" class="text-sm font-medium text-green-700">Paid out {{ result.paid_sats }} sats!</p>
<button class="btn-primary" :disabled="withdrawing || !lud16" @click="withdraw">
{{ withdrawing ? 'Paying out' : 'Withdraw' }}
</button>
</div>
</div>
<section v-if="earnings.length">
<h3 class="mb-3 text-sm font-semibold uppercase tracking-wide text-slate-500">History</h3>
<ul class="space-y-2">
<li v-for="e in earnings" :key="e.id" class="card flex items-center justify-between !p-4">
<span class="font-medium">{{ e.amount_sats }} sats</span>
<span
class="rounded-full px-2 py-0.5 text-xs font-semibold"
:class="e.withdrawn_at ? 'bg-slate-100 text-slate-600' : 'bg-green-100 text-green-700'"
>{{ e.withdrawn_at ? 'withdrawn' : 'available' }}</span>
</li>
</ul>
</section>
</template>
</div>
</template>
+131
View File
@@ -0,0 +1,131 @@
<script setup lang="ts">
// The page a priced episode's RSS <enclosure> now points at (see server/src/services/rss.ts):
// shows what's being sold, lets a listener buy it from the producer or a certified reseller,
// and lets an existing buyer list themselves as a reseller or grab the file once unlocked.
import { onMounted, ref } from 'vue';
import { useRoute } from 'vue-router';
import { api, ApiError } from '../lib/api';
import EpisodeSources from '../components/EpisodeSources.vue';
const route = useRoute();
const podcastId = route.params.id as string;
const episodeId = route.params.eid as string;
const title = ref('');
const description = ref('');
const podcastTitle = ref('');
const priceSats = ref<number | null>(null);
const loading = ref(true);
const notFoundOrFree = ref(false);
const downloadUrl = ref<string | null>(null);
const checkingAccess = ref(true);
const resellUrl = ref('');
const resellPrice = ref<number | null>(null);
const resellBusy = ref(false);
const resellError = ref('');
const resellDone = ref(false);
async function loadInfo() {
try {
const res = await api.get<{
episode: { title: string; description: string };
podcast: { title: string };
producer: { price_sats: number };
}>(`/api/podcasts/${podcastId}/episodes/${episodeId}/sources`);
title.value = res.episode.title;
description.value = res.episode.description;
podcastTitle.value = res.podcast.title;
priceSats.value = res.producer.price_sats;
} catch (err) {
if (err instanceof ApiError && (err.status === 400 || err.status === 404)) {
notFoundOrFree.value = true;
} else {
throw err;
}
} finally {
loading.value = false;
}
}
async function checkAccess() {
checkingAccess.value = true;
try {
const res = await api.get<{ url: string }>(`/api/podcasts/${podcastId}/episodes/${episodeId}/download-url`);
downloadUrl.value = res.url;
} catch {
downloadUrl.value = null;
} finally {
checkingAccess.value = false;
}
}
onMounted(async () => {
await loadInfo();
if (!notFoundOrFree.value) await checkAccess();
});
async function becomeReseller() {
resellError.value = '';
resellBusy.value = true;
try {
await api.post(`/api/podcasts/${podcastId}/episodes/${episodeId}/resellers`, {
download_url: resellUrl.value,
price_sats: resellPrice.value,
});
resellDone.value = true;
} catch (err) {
resellError.value = (err as Error).message;
} finally {
resellBusy.value = false;
}
}
</script>
<template>
<div class="mx-auto max-w-2xl space-y-6">
<p v-if="loading" class="text-sm text-slate-500">Loading</p>
<p v-else-if="notFoundOrFree" class="rounded-lg bg-amber-50 p-3 text-sm text-amber-800">
This episode isn't available for sale (it may be free, or no longer exists).
</p>
<template v-else>
<div>
<p class="text-sm text-slate-500">{{ podcastTitle }}</p>
<h1 class="text-2xl font-bold">{{ title }}</h1>
<p class="mt-2 whitespace-pre-wrap text-slate-600">{{ description }}</p>
</div>
<div v-if="checkingAccess" class="text-sm text-slate-500">Checking access</div>
<div v-else-if="downloadUrl" class="card space-y-4">
<h2 class="font-semibold text-green-700">🔓 You have access to this episode</h2>
<a :href="downloadUrl" class="btn-primary inline-block" target="_blank" rel="noopener">Download</a>
<div class="border-t border-slate-100 pt-4">
<h3 class="font-semibold">List your copy for resale</h3>
<p class="mb-2 text-xs text-slate-400">
Point at a Blossom-compatible host serving this same file the producer still gets
a cut of anything sold through you.
</p>
<template v-if="!resellDone">
<div class="space-y-2">
<input v-model="resellUrl" class="input" type="url" placeholder="https://your-blossom-host.example" />
<input v-model.number="resellPrice" class="input" type="number" min="1" placeholder="Your asking price (sats)" />
<p v-if="resellError" class="text-sm text-red-700">{{ resellError }}</p>
<button class="btn-secondary" :disabled="resellBusy || !resellUrl || !resellPrice" @click="becomeReseller">
{{ resellBusy ? 'Certifying' : 'List as reseller' }}
</button>
</div>
</template>
<p v-else class="text-sm font-medium text-green-700">You're now a certified reseller for this episode.</p>
</div>
</div>
<div v-else class="space-y-3">
<h2 class="font-semibold">{{ priceSats }} sats to unlock</h2>
<EpisodeSources :podcast-id="podcastId" :episode-id="episodeId" @purchased="checkAccess" />
</div>
</template>
</div>
</template>
@@ -23,6 +23,7 @@ const error = ref('');
const epTitle = ref('');
const epDescription = ref('');
const epPriceSats = ref<number | null>(null);
const uploaded = ref<{ sha256: string; size: number } | null>(null);
const durationSecs = ref<number | null>(null);
const episodeUrl = ref('');
@@ -86,6 +87,7 @@ async function publish() {
size: file.value.size,
mime: file.value.type || 'video/mp4',
duration_secs: durationSecs.value,
price_sats: epPriceSats.value || undefined,
},
);
episodeUrl.value = episode.enclosure_url;
@@ -145,6 +147,14 @@ async function publish() {
<label class="label" for="ep-desc">Show notes</label>
<textarea id="ep-desc" v-model="epDescription" class="input" rows="4" placeholder="What happens in this episode?" />
</div>
<div>
<label class="label" for="ep-price">Price in sats (optional leave blank for a free episode)</label>
<input id="ep-price" v-model.number="epPriceSats" type="number" min="1" step="1" class="input" placeholder="Free" />
<p class="text-xs text-slate-400">
Buyers pay this to unlock the download, then get a certified receipt they can
re-sell the same file with you get a cut of resales too (see podcast settings).
</p>
</div>
<div v-if="phase !== 'idle'" class="space-y-2">
<div class="h-2 overflow-hidden rounded-full bg-slate-200">
<div class="h-full bg-puddle-500 transition-all" :style="{ width: `${progress * 100}%` }" />