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:
@@ -13,3 +13,6 @@ BLOSSOM_URL_DEFAULT=http://${PUBLIC_HOST}:8098
|
||||
# Default nostr relays for NIP-53 live-event announcements (comma separated,
|
||||
# changeable at runtime in Settings)
|
||||
NOSTR_RELAYS=wss://relay.damus.io,wss://nos.lol,wss://relay.nostr.band
|
||||
|
||||
# Cashu mint used to settle paid-episode purchases (changeable at runtime in Settings)
|
||||
CASHU_MINT_URL_DEFAULT=https://mint.minibits.cash/Bitcoin
|
||||
|
||||
+2
-1
@@ -20,6 +20,7 @@ services:
|
||||
BLOSSOM_URL_DEFAULT: ${BLOSSOM_URL_DEFAULT:-http://localhost:8098}
|
||||
BLOSSOM_URL_INTERNAL: http://blossom:3000
|
||||
NOSTR_RELAYS: ${NOSTR_RELAYS:-wss://relay.damus.io,wss://nos.lol,wss://relay.nostr.band}
|
||||
CASHU_MINT_URL_DEFAULT: ${CASHU_MINT_URL_DEFAULT:-https://mint.minibits.cash/Bitcoin}
|
||||
volumes:
|
||||
- podsteadr-data:/data
|
||||
- mediamtx-recordings:/recordings:ro
|
||||
@@ -28,7 +29,7 @@ services:
|
||||
- blossom
|
||||
|
||||
mediamtx:
|
||||
image: bluenviron/mediamtx:1.19.2
|
||||
image: docker.io/bluenviron/mediamtx:1.19.2
|
||||
container_name: podsteadr-mediamtx
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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') },
|
||||
],
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
@@ -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}%` }" />
|
||||
|
||||
@@ -148,5 +148,77 @@ const authBad = await api('POST', '/api/mediamtx/auth', {
|
||||
});
|
||||
ok('mediamtx auth rejects wrong key', authBad.status === 401);
|
||||
|
||||
// ---- marketplace: paywalled episode + Cashu purchase (producer side + invoice request only —
|
||||
// actually paying the invoice and confirming needs a real sat payment, which this script can't
|
||||
// do headlessly; that step is a manual check, see docs/STATUS.md). Uses a fresh throwaway
|
||||
// buyer identity so it never collides with the persisted admin/producer one above. ----
|
||||
const pricedEpisode = await api('POST', `/api/podcasts/${podcastId}/episodes`, {
|
||||
title: 'E2E Priced Episode',
|
||||
description: 'Paywalled',
|
||||
sha256,
|
||||
size: media.length,
|
||||
mime: 'video/mp4',
|
||||
price_sats: 21,
|
||||
});
|
||||
ok('priced episode registered', pricedEpisode.status === 201 && pricedEpisode.json.price_sats === 21);
|
||||
const pricedEpisodeId = pricedEpisode.json.id;
|
||||
|
||||
const buyerSk = generateSecretKey();
|
||||
let buyerCookie = '';
|
||||
async function apiAsBuyer(method, path, body, headers = {}) {
|
||||
const res = await fetch(`${BASE}${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
...(body ? { 'content-type': 'application/json' } : {}),
|
||||
...(buyerCookie ? { cookie: buyerCookie } : {}),
|
||||
...headers,
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
const setCookie = res.headers.get('set-cookie');
|
||||
if (setCookie) buyerCookie = setCookie.split(';')[0];
|
||||
let json = null;
|
||||
try { json = await res.json(); } catch { /* non-JSON */ }
|
||||
return { status: res.status, json };
|
||||
}
|
||||
function nip98As(signingKey, url, method) {
|
||||
const event = finalizeEvent(
|
||||
{
|
||||
kind: 27235,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
content: '',
|
||||
tags: [['u', url], ['method', method], ['nonce', Math.random().toString(36).slice(2)]],
|
||||
},
|
||||
signingKey,
|
||||
);
|
||||
return `Nostr ${Buffer.from(JSON.stringify(event)).toString('base64')}`;
|
||||
}
|
||||
|
||||
const buyerLogin = await apiAsBuyer('POST', '/api/auth/login', undefined, {
|
||||
authorization: nip98As(buyerSk, `${BASE}/api/auth/login`, 'POST'),
|
||||
});
|
||||
ok('buyer identity logs in', buyerLogin.status === 200 && buyerLogin.json.isAdmin === false);
|
||||
|
||||
const gateBefore = await apiAsBuyer('GET', `/api/podcasts/${podcastId}/episodes/${pricedEpisodeId}/download-url`);
|
||||
ok('download-url is gated before purchase', gateBefore.status === 402, `status ${gateBefore.status}`);
|
||||
|
||||
const sources = await fetch(`${BASE}/api/podcasts/${podcastId}/episodes/${pricedEpisodeId}/sources`);
|
||||
const sourcesJson = await sources.json();
|
||||
ok('sources lists the producer at the right price', sourcesJson.producer?.price_sats === 21);
|
||||
|
||||
const purchase = await apiAsBuyer('POST', `/api/podcasts/${podcastId}/episodes/${pricedEpisodeId}/purchase`, {
|
||||
source: 'producer',
|
||||
});
|
||||
ok(
|
||||
'purchase requests a real invoice from the configured Cashu mint',
|
||||
purchase.status === 201 && purchase.json?.invoice?.startsWith('lnbc'),
|
||||
purchase.json?.error ?? `invoice ${purchase.json?.invoice?.slice(0, 20)}…`,
|
||||
);
|
||||
console.log(
|
||||
` -> to finish this check by hand: pay ${purchase.json?.invoice}, then ` +
|
||||
`POST /api/podcasts/${podcastId}/episodes/${pricedEpisodeId}/purchase/${purchase.json?.quoteId}/confirm ` +
|
||||
`as the buyer and re-check download-url.`,
|
||||
);
|
||||
|
||||
console.log(JSON.stringify({ streamId, streamKey: stream.json.streamKey, podcastId }));
|
||||
process.exit(failures === 0 ? 0 : 1);
|
||||
|
||||
Generated
+103
@@ -8,7 +8,9 @@
|
||||
"name": "podsteadr-server",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@cashu/cashu-ts": "^4.7.2",
|
||||
"@fastify/cookie": "^11.0.2",
|
||||
"@fastify/cors": "^11.3.0",
|
||||
"@fastify/static": "^8.1.1",
|
||||
"better-sqlite3": "^12.2.0",
|
||||
"fastify": "^5.4.0",
|
||||
@@ -26,6 +28,71 @@
|
||||
"vitest": "^3.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@cashu/cashu-ts": {
|
||||
"version": "4.7.2",
|
||||
"resolved": "https://registry.npmjs.org/@cashu/cashu-ts/-/cashu-ts-4.7.2.tgz",
|
||||
"integrity": "sha512-rNfArnXhaoUCi7crcLFwayHFQ9ek9VUI0EemBbleC9dNEhrJDGBdyqkFH7b6ubmA2Pej0DskUfiLRx0fCp0ebQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/curves": "^2.2.0",
|
||||
"@noble/hashes": "^2.2.0",
|
||||
"@scure/base": "^2.2.0",
|
||||
"@scure/bip32": "^2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@cashu/cashu-ts/node_modules/@noble/curves": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.2.0.tgz",
|
||||
"integrity": "sha512-T/BoHgFXirb0ENSPBquzX0rcjXeM6Lo892a2jlYJkqk83LqZx0l1Of7DzlKJ6jkpvMrkHSnAcgb5JegL8SeIkQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/hashes": "2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@cashu/cashu-ts/node_modules/@noble/hashes": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz",
|
||||
"integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@cashu/cashu-ts/node_modules/@scure/base": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@scure/base/-/base-2.2.0.tgz",
|
||||
"integrity": "sha512-b8XEupJibegiXV+tDUseI8oLQc8ei3d/4Jkb2RpbHh3MfE054ov3uIz2dhFkB3FI8iwYkEh0gGCApkrYggkPNg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@cashu/cashu-ts/node_modules/@scure/bip32": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-2.2.0.tgz",
|
||||
"integrity": "sha512-zFr7t2F+a9+5tB7QbarF2HQNYrgjCNaoLAupZdKkrFMYMozJf5zqH2WJCQibMzm1qQ0QogrxVGO3qXfQDYMaQg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/curves": "2.2.0",
|
||||
"@noble/hashes": "2.2.0",
|
||||
"@scure/base": "2.2.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
|
||||
@@ -541,6 +608,42 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@fastify/cors": {
|
||||
"version": "11.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-11.3.0.tgz",
|
||||
"integrity": "sha512-ggQGua+xHv1MvePbPr0v//xLYEsCXbWspquXCJS9Ot5YoRXq8J8ZWzHnxDBVnbtXosvistXo6LtNzOJswf64Fw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fastify-plugin": "^6.0.0",
|
||||
"toad-cache": "^3.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@fastify/cors/node_modules/fastify-plugin": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-6.0.0.tgz",
|
||||
"integrity": "sha512-fZOty7z3O7vOliF6d8bHE3wiEh1KcNnKEQensSgTk9C1DvN6nRLS++XVd86v33Hw/8u9Un8A1zDrQ8ujcQDHEg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@fastify/error": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz",
|
||||
|
||||
@@ -11,7 +11,9 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@cashu/cashu-ts": "^4.7.2",
|
||||
"@fastify/cookie": "^11.0.2",
|
||||
"@fastify/cors": "^11.3.0",
|
||||
"@fastify/static": "^8.1.1",
|
||||
"better-sqlite3": "^12.2.0",
|
||||
"fastify": "^5.4.0",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import cookie from '@fastify/cookie';
|
||||
import cors from '@fastify/cors';
|
||||
import fastifyStatic from '@fastify/static';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
@@ -9,12 +10,14 @@ import nostrAuth from './plugins/nostr-auth.js';
|
||||
import { SettingsService } from './services/settings.js';
|
||||
import { NostrPublisher, loadServerKey } from './services/nostr.js';
|
||||
import { MediamtxClient } from './services/mediamtx.js';
|
||||
import { CashuClientPool } from './services/cashu.js';
|
||||
import authRoutes from './routes/auth.js';
|
||||
import settingsRoutes from './routes/settings.js';
|
||||
import podcastRoutes from './routes/podcasts.js';
|
||||
import feedRoutes from './routes/feeds.js';
|
||||
import streamRoutes from './routes/streams.js';
|
||||
import mediamtxRoutes from './routes/mediamtx.js';
|
||||
import marketplaceRoutes from './routes/marketplace.js';
|
||||
|
||||
export interface AppContext {
|
||||
config: Config;
|
||||
@@ -23,6 +26,7 @@ export interface AppContext {
|
||||
publisher: NostrPublisher;
|
||||
mediamtx: MediamtxClient;
|
||||
serverKey: Uint8Array;
|
||||
cashu: CashuClientPool;
|
||||
}
|
||||
|
||||
declare module 'fastify' {
|
||||
@@ -55,10 +59,18 @@ export async function buildApp(opts: BuildAppOptions): Promise<FastifyInstance>
|
||||
}),
|
||||
mediamtx: new MediamtxClient(config.MEDIAMTX_API_URL),
|
||||
serverKey,
|
||||
cashu: new CashuClientPool(db),
|
||||
};
|
||||
app.decorate('ctx', ctx);
|
||||
|
||||
await app.register(cookie);
|
||||
// The catalog/feed/sources endpoints are deliberately public and crawlable (see
|
||||
// routes/marketplace.ts, routes/feeds.ts), and the purchase endpoints already accept
|
||||
// stateless NIP-98 header auth for exactly this "external client" case (routes/marketplace.ts,
|
||||
// plugins/nostr-auth.ts) — so external clients like podsteadr-player, other podsteadr
|
||||
// instances, or crawlers can be reflected on any origin. `credentials` stays off, so the
|
||||
// session cookie is never sent cross-origin — cookie-authed admin routes stay same-origin-only.
|
||||
await app.register(cors, { origin: true, methods: ['GET', 'POST', 'DELETE'] });
|
||||
await app.register(nostrAuth, { db, config });
|
||||
|
||||
app.get('/api/health', async () => ({ status: 'ok' }));
|
||||
@@ -69,6 +81,7 @@ export async function buildApp(opts: BuildAppOptions): Promise<FastifyInstance>
|
||||
await app.register(feedRoutes);
|
||||
await app.register(streamRoutes);
|
||||
await app.register(mediamtxRoutes);
|
||||
await app.register(marketplaceRoutes);
|
||||
|
||||
// Serve the built frontend (SPA fallback for client-side routes).
|
||||
const staticDir = config.STATIC_DIR;
|
||||
|
||||
@@ -17,6 +17,7 @@ const envSchema = z.object({
|
||||
// BLOSSOM_URL_DEFAULT, which is not resolvable inside the container).
|
||||
BLOSSOM_URL_INTERNAL: z.string().url().optional(),
|
||||
NOSTR_RELAYS: z.string().default('wss://relay.damus.io,wss://nos.lol'),
|
||||
CASHU_MINT_URL_DEFAULT: z.string().url().default('https://mint.minibits.cash/Bitcoin'),
|
||||
NIP98_MAX_SKEW_SECS: z.coerce.number().default(60),
|
||||
SESSION_TTL_DAYS: z.coerce.number().default(30),
|
||||
MEDIAMTX_POLL_INTERVAL_MS: z.coerce.number().default(3000),
|
||||
|
||||
@@ -90,6 +90,64 @@ CREATE TABLE auth_events (
|
||||
event_id TEXT PRIMARY KEY,
|
||||
seen_at INTEGER NOT NULL
|
||||
);
|
||||
`,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
sql: `
|
||||
ALTER TABLE episodes ADD COLUMN price_sats INTEGER;
|
||||
ALTER TABLE podcasts ADD COLUMN resale_producer_share_pct INTEGER NOT NULL DEFAULT 50;
|
||||
|
||||
CREATE TABLE purchases (
|
||||
id TEXT PRIMARY KEY,
|
||||
episode_id TEXT NOT NULL REFERENCES episodes(id) ON DELETE CASCADE,
|
||||
buyer_pubkey TEXT NOT NULL,
|
||||
seller_pubkey TEXT NOT NULL,
|
||||
generation INTEGER NOT NULL,
|
||||
amount_sats INTEGER NOT NULL,
|
||||
receipt_event_id TEXT NOT NULL,
|
||||
receipt_json TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX idx_purchases_episode_buyer ON purchases(episode_id, buyer_pubkey);
|
||||
CREATE INDEX idx_purchases_seller ON purchases(seller_pubkey);
|
||||
|
||||
CREATE TABLE resellers (
|
||||
episode_id TEXT NOT NULL REFERENCES episodes(id) ON DELETE CASCADE,
|
||||
pubkey TEXT NOT NULL,
|
||||
download_url TEXT NOT NULL,
|
||||
price_sats INTEGER NOT NULL,
|
||||
revoked_at INTEGER,
|
||||
created_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (episode_id, pubkey)
|
||||
);
|
||||
|
||||
CREATE TABLE earnings (
|
||||
id TEXT PRIMARY KEY,
|
||||
purchase_id TEXT NOT NULL REFERENCES purchases(id) ON DELETE CASCADE,
|
||||
pubkey TEXT NOT NULL,
|
||||
amount_sats INTEGER NOT NULL,
|
||||
withdrawn_at INTEGER
|
||||
);
|
||||
CREATE INDEX idx_earnings_pubkey ON earnings(pubkey, withdrawn_at);
|
||||
|
||||
CREATE TABLE cashu_quotes (
|
||||
quote_id TEXT PRIMARY KEY,
|
||||
purchase_ctx TEXT NOT NULL,
|
||||
state TEXT NOT NULL DEFAULT 'pending' CHECK (state IN ('pending','paid','settled','expired')),
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
-- Server-custodied Cashu ecash: each row is one encoded token (a batch of proofs)
|
||||
-- from either a completed mint (buyer payment) or melt change (overpaid fee reserve).
|
||||
CREATE TABLE cashu_proofs (
|
||||
id TEXT PRIMARY KEY,
|
||||
proofs_json TEXT NOT NULL,
|
||||
amount_sats INTEGER NOT NULL,
|
||||
spent_at INTEGER,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX idx_cashu_proofs_unspent ON cashu_proofs(spent_at);
|
||||
`,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { buildFeedXml } from '../services/rss.js';
|
||||
import { buildCatalogOpml, buildFeedXml } from '../services/rss.js';
|
||||
import { getEpisodeSources, type EpisodeSources } from '../services/marketplace.js';
|
||||
import type { Episode, Podcast } from '../types.js';
|
||||
|
||||
export default async function feedRoutes(app: FastifyInstance) {
|
||||
@@ -8,6 +9,7 @@ export default async function feedRoutes(app: FastifyInstance) {
|
||||
|
||||
const getPodcast = db.prepare('SELECT * FROM podcasts WHERE id = ?');
|
||||
const listEpisodes = db.prepare('SELECT * FROM episodes WHERE podcast_id = ? ORDER BY pub_date DESC');
|
||||
const listAllPodcasts = db.prepare('SELECT * FROM podcasts ORDER BY created_at');
|
||||
|
||||
app.get('/feeds/:id/feed.xml', async (req, reply) => {
|
||||
const { id } = req.params as { id: string };
|
||||
@@ -15,7 +17,12 @@ export default async function feedRoutes(app: FastifyInstance) {
|
||||
if (!podcast) return reply.code(404).send({ error: 'feed not found' });
|
||||
|
||||
const episodes = listEpisodes.all(id) as Episode[];
|
||||
const xml = buildFeedXml(podcast, episodes, { publicUrl: settings.all().public_url });
|
||||
const sourcesByEpisode = new Map<string, EpisodeSources>();
|
||||
for (const e of episodes) {
|
||||
const sources = getEpisodeSources(db, e, podcast);
|
||||
if (sources) sourcesByEpisode.set(e.id, sources);
|
||||
}
|
||||
const xml = buildFeedXml(podcast, episodes, { publicUrl: settings.all().public_url, sourcesByEpisode });
|
||||
const etag = `"${createHash('sha256').update(xml).digest('hex').slice(0, 16)}"`;
|
||||
|
||||
if (req.headers['if-none-match'] === etag) return reply.code(304).send();
|
||||
@@ -25,4 +32,12 @@ export default async function feedRoutes(app: FastifyInstance) {
|
||||
.header('cache-control', 'public, max-age=60')
|
||||
.send(xml);
|
||||
});
|
||||
|
||||
// Every feed this instance hosts, in one request — the open, RSS-native catalog for
|
||||
// discovery by other podsteadr servers or any OPML-aware crawler.
|
||||
app.get('/catalog.opml', async (_req, reply) => {
|
||||
const podcasts = listAllPodcasts.all() as Podcast[];
|
||||
const xml = buildCatalogOpml(podcasts, { publicUrl: settings.all().public_url });
|
||||
return reply.header('content-type', 'text/x-opml; charset=utf-8').send(xml);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,360 @@
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { finalizeEvent, generateSecretKey, getPublicKey, verifyEvent } from 'nostr-tools/pure';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
|
||||
// The real CashuMintClient talks to a live mint over the network (NUT-04/05), which isn't
|
||||
// available in this test environment — fake it deterministically so the surrounding purchase
|
||||
// flow (DB writes, receipt signing, paywall gating) can be verified end to end.
|
||||
let nextQuote = 0;
|
||||
vi.mock('../services/cashu.js', () => {
|
||||
class FakeCashuMintClient {
|
||||
async requestMintQuote(amountSats: number) {
|
||||
return { quoteId: `fake-quote-${++nextQuote}`, invoice: `lnbc-fake-invoice-for-${amountSats}-sats` };
|
||||
}
|
||||
async isQuotePaid() {
|
||||
return true;
|
||||
}
|
||||
async mintAndStore() {
|
||||
return 'fake-proof-row-id';
|
||||
}
|
||||
async payout(lud16: string, amountSats: number) {
|
||||
return { preimage: `fake-preimage-for-${lud16}-${amountSats}` };
|
||||
}
|
||||
}
|
||||
class CashuClientPool {
|
||||
client() {
|
||||
return new FakeCashuMintClient();
|
||||
}
|
||||
}
|
||||
class CashuUnreachableError extends Error {}
|
||||
class CashuInsufficientFundsError extends Error {}
|
||||
return { CashuClientPool, CashuUnreachableError, CashuInsufficientFundsError };
|
||||
});
|
||||
|
||||
const { buildApp } = await import('../app.js');
|
||||
const { loadConfig } = await import('../config.js');
|
||||
|
||||
const producerSk = generateSecretKey();
|
||||
const producerPk = getPublicKey(producerSk);
|
||||
const buyerSk = generateSecretKey();
|
||||
const buyerPk = getPublicKey(buyerSk);
|
||||
|
||||
let app: FastifyInstance;
|
||||
let dataDir: string;
|
||||
let producerCookie: string;
|
||||
let buyerCookie: string;
|
||||
let podcastId: string;
|
||||
let episodeId: string;
|
||||
const sha = 'e'.repeat(64);
|
||||
|
||||
function nip98Header(sk: Uint8Array, url: string, method: string): string {
|
||||
const event = finalizeEvent(
|
||||
{
|
||||
kind: 27235,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
content: '',
|
||||
tags: [['u', url], ['method', method], ['nonce', Math.random().toString(36).slice(2)]],
|
||||
},
|
||||
sk,
|
||||
);
|
||||
return `Nostr ${Buffer.from(JSON.stringify(event)).toString('base64')}`;
|
||||
}
|
||||
|
||||
async function login(sk: Uint8Array): Promise<string> {
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/auth/login',
|
||||
headers: { authorization: nip98Header(sk, 'http://localhost:8095/api/auth/login', 'POST') },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
return (res.headers['set-cookie'] as string).split(';')[0];
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
dataDir = mkdtempSync(join(tmpdir(), 'podsteadr-marketplace-test-'));
|
||||
const config = loadConfig({
|
||||
DATA_DIR: dataDir,
|
||||
PUBLIC_URL: 'http://localhost:8095',
|
||||
NOSTR_RELAYS: '',
|
||||
} as NodeJS.ProcessEnv);
|
||||
app = await buildApp({ config, dbPath: ':memory:', logger: false });
|
||||
|
||||
producerCookie = await login(producerSk);
|
||||
buyerCookie = await login(buyerSk);
|
||||
|
||||
const podcastRes = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/podcasts',
|
||||
headers: { cookie: producerCookie },
|
||||
payload: { title: 'Paid Show', lightning_address: 'producer@getalby.com' },
|
||||
});
|
||||
podcastId = podcastRes.json().id;
|
||||
|
||||
vi.stubGlobal('fetch', vi.fn(async () => new Response(null, { status: 200, headers: { 'content-length': '1000' } })));
|
||||
const episodeRes = await app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/podcasts/${podcastId}/episodes`,
|
||||
headers: { cookie: producerCookie },
|
||||
payload: { title: 'Paid Ep', sha256: sha, size: 1000, mime: 'video/mp4', price_sats: 500 },
|
||||
});
|
||||
vi.unstubAllGlobals();
|
||||
episodeId = episodeRes.json().id;
|
||||
expect(episodeRes.json().price_sats).toBe(500);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
rmSync(dataDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('paywall gating', () => {
|
||||
it('denies download-url before purchase', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/podcasts/${podcastId}/episodes/${episodeId}/download-url`,
|
||||
headers: { cookie: buyerCookie },
|
||||
});
|
||||
expect(res.statusCode).toBe(402);
|
||||
expect(res.json().price_sats).toBe(500);
|
||||
});
|
||||
|
||||
it('always lets the producer through', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/podcasts/${podcastId}/episodes/${episodeId}/download-url`,
|
||||
headers: { cookie: producerCookie },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().url).toContain(`${sha}.mp4`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('purchase flow', () => {
|
||||
let quoteId: string;
|
||||
|
||||
it('rejects the producer buying their own episode', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/podcasts/${podcastId}/episodes/${episodeId}/purchase`,
|
||||
headers: { cookie: producerCookie },
|
||||
payload: { source: 'producer' },
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it('requests a mint quote for the episode price', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/podcasts/${podcastId}/episodes/${episodeId}/purchase`,
|
||||
headers: { cookie: buyerCookie },
|
||||
payload: { source: 'producer' },
|
||||
});
|
||||
expect(res.statusCode).toBe(201);
|
||||
expect(res.json().amountSats).toBe(500);
|
||||
expect(res.json().invoice).toMatch(/^lnbc-fake-invoice-for-500-sats/);
|
||||
quoteId = res.json().quoteId;
|
||||
});
|
||||
|
||||
it('confirms the purchase and returns a valid signed receipt', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/podcasts/${podcastId}/episodes/${episodeId}/purchase/${quoteId}/confirm`,
|
||||
headers: { cookie: buyerCookie },
|
||||
});
|
||||
expect(res.statusCode).toBe(201);
|
||||
const purchase = res.json();
|
||||
expect(purchase.buyer_pubkey).toBe(buyerPk);
|
||||
expect(purchase.seller_pubkey).toBe(producerPk);
|
||||
expect(purchase.amount_sats).toBe(500);
|
||||
expect(purchase.generation).toBe(0);
|
||||
|
||||
const receipt = JSON.parse(purchase.receipt_json);
|
||||
expect(receipt.kind).toBe(30356);
|
||||
expect(verifyEvent(receipt)).toBe(true);
|
||||
expect(receipt.tags).toContainEqual(['x', sha]);
|
||||
expect(receipt.tags).toContainEqual(['p', buyerPk, '', 'buyer']);
|
||||
expect(receipt.tags).toContainEqual(['p', producerPk, '', 'seller']);
|
||||
});
|
||||
|
||||
it('re-confirming the same quote is idempotent', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/podcasts/${podcastId}/episodes/${episodeId}/purchase/${quoteId}/confirm`,
|
||||
headers: { cookie: buyerCookie },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it('rejects buying the same episode twice', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/podcasts/${podcastId}/episodes/${episodeId}/purchase`,
|
||||
headers: { cookie: buyerCookie },
|
||||
payload: { source: 'producer' },
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it('lets the buyer through download-url after purchase', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/podcasts/${podcastId}/episodes/${episodeId}/download-url`,
|
||||
headers: { cookie: buyerCookie },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().url).toContain(`${sha}.mp4`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resale', () => {
|
||||
const resellerMirrorUrl = 'https://mirror.example.com';
|
||||
let resellerQuoteId: string;
|
||||
let firstPurchaseId: string;
|
||||
|
||||
it('refuses to certify a reseller who never purchased', async () => {
|
||||
const strangerSk = generateSecretKey();
|
||||
const strangerCookie = await login(strangerSk);
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/podcasts/${podcastId}/episodes/${episodeId}/resellers`,
|
||||
headers: { cookie: strangerCookie },
|
||||
payload: { download_url: resellerMirrorUrl, price_sats: 300 },
|
||||
});
|
||||
expect(res.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it('certifies the original buyer as a reseller after verifying their mirror', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => new Response(null, { status: 200, headers: { 'content-length': '1000' } })));
|
||||
let res;
|
||||
try {
|
||||
res = await app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/podcasts/${podcastId}/episodes/${episodeId}/resellers`,
|
||||
headers: { cookie: buyerCookie },
|
||||
payload: { download_url: resellerMirrorUrl, price_sats: 300 },
|
||||
});
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
expect(res!.statusCode).toBe(201);
|
||||
expect(res!.json().pubkey).toBe(buyerPk);
|
||||
expect(res!.json().price_sats).toBe(300);
|
||||
});
|
||||
|
||||
it('lists the producer and the certified reseller as sources', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/podcasts/${podcastId}/episodes/${episodeId}/sources`,
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(body.producer).toMatchObject({ pubkey: producerPk, price_sats: 500 });
|
||||
expect(body.resellers).toHaveLength(1);
|
||||
expect(body.resellers[0]).toMatchObject({ pubkey: buyerPk, price_sats: 300, sales_count: 0 });
|
||||
expect(body.resellers[0].url).toBe(`${resellerMirrorUrl}/${sha}.mp4`);
|
||||
});
|
||||
|
||||
it('a second listener buys from the reseller instead of the producer', async () => {
|
||||
const secondBuyerSk = generateSecretKey();
|
||||
const secondBuyerCookie = await login(secondBuyerSk);
|
||||
|
||||
const purchaseRes = await app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/podcasts/${podcastId}/episodes/${episodeId}/purchase`,
|
||||
headers: { cookie: secondBuyerCookie },
|
||||
payload: { source: buyerPk },
|
||||
});
|
||||
expect(purchaseRes.statusCode).toBe(201);
|
||||
expect(purchaseRes.json().amountSats).toBe(300);
|
||||
resellerQuoteId = purchaseRes.json().quoteId;
|
||||
|
||||
const confirmRes = await app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/podcasts/${podcastId}/episodes/${episodeId}/purchase/${resellerQuoteId}/confirm`,
|
||||
headers: { cookie: secondBuyerCookie },
|
||||
});
|
||||
expect(confirmRes.statusCode).toBe(201);
|
||||
const purchase = confirmRes.json();
|
||||
expect(purchase.seller_pubkey).toBe(buyerPk);
|
||||
expect(purchase.generation).toBe(1);
|
||||
expect(purchase.amount_sats).toBe(300);
|
||||
firstPurchaseId = purchase.id;
|
||||
|
||||
const receipt = JSON.parse(purchase.receipt_json);
|
||||
expect(receipt.tags).toContainEqual(['p', buyerPk, '', 'seller']);
|
||||
expect(receipt.tags.some((t: string[]) => t[0] === 'e')).toBe(true);
|
||||
});
|
||||
|
||||
it('reflects the new sale in the reseller\'s reputation count', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/podcasts/${podcastId}/episodes/${episodeId}/sources`,
|
||||
});
|
||||
expect(res.json().resellers[0].sales_count).toBe(1);
|
||||
expect(firstPurchaseId).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('earnings', () => {
|
||||
it('credits the producer for both the direct sale and their share of the resale', async () => {
|
||||
const res = await app.inject({ method: 'GET', url: '/api/earnings', headers: { cookie: producerCookie } });
|
||||
expect(res.statusCode).toBe(200);
|
||||
// 500 sats direct sale + 50% of the 300-sat resale (default resale_producer_share_pct)
|
||||
expect(res.json().unwithdrawn_sats).toBe(500 + 150);
|
||||
});
|
||||
|
||||
it("credits the reseller for their share of the resale they made", async () => {
|
||||
const res = await app.inject({ method: 'GET', url: '/api/earnings', headers: { cookie: buyerCookie } });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().unwithdrawn_sats).toBe(150);
|
||||
});
|
||||
|
||||
it('refuses to withdraw without a lightning address on file', async () => {
|
||||
const res = await app.inject({ method: 'POST', url: '/api/earnings/withdraw', headers: { cookie: buyerCookie } });
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it('withdraws to a provided lud16 and zeroes the balance', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/earnings/withdraw',
|
||||
headers: { cookie: buyerCookie },
|
||||
payload: { lud16: 'reseller@getalby.com' },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().paid_sats).toBe(150);
|
||||
expect(res.json().preimage).toContain('reseller@getalby.com');
|
||||
|
||||
const after = await app.inject({ method: 'GET', url: '/api/earnings', headers: { cookie: buyerCookie } });
|
||||
expect(after.json().unwithdrawn_sats).toBe(0);
|
||||
});
|
||||
|
||||
it('remembers the lud16 for next time', async () => {
|
||||
const res = await app.inject({ method: 'GET', url: '/api/auth/me', headers: { cookie: buyerCookie } });
|
||||
expect(res.json().lud16).toBe('reseller@getalby.com');
|
||||
});
|
||||
});
|
||||
|
||||
describe('open catalog discovery (RSS + OPML)', () => {
|
||||
it('lists the producer and certified reseller as podsteadr:source tags in the RSS feed', async () => {
|
||||
const res = await app.inject({ method: 'GET', url: `/feeds/${podcastId}/feed.xml` });
|
||||
expect(res.statusCode).toBe(200);
|
||||
const xml = res.body;
|
||||
expect(xml).toContain('xmlns:podsteadr="https://podsteadr.dev/ns/1.0"');
|
||||
expect(xml).toContain(`<podsteadr:source type="producer" pubkey="${producerPk}" price="500"`);
|
||||
expect(xml).toContain(`<podsteadr:source type="reseller" pubkey="${buyerPk}" price="300"`);
|
||||
// the reseller's own mirror url must appear too — the whole point is it's discoverable
|
||||
expect(xml).toContain('https://mirror.example.com');
|
||||
});
|
||||
|
||||
it('lists this podcast in the instance-wide OPML catalog', async () => {
|
||||
const res = await app.inject({ method: 'GET', url: '/catalog.opml' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.headers['content-type']).toContain('text/x-opml');
|
||||
expect(res.body).toContain('<opml version="2.0">');
|
||||
expect(res.body).toContain(`xmlUrl="http://localhost:8095/feeds/${podcastId}/feed.xml"`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,332 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { checkBlob, BlossomUnreachableError } from '../services/blossom.js';
|
||||
import { CashuUnreachableError, CashuInsufficientFundsError } from '../services/cashu.js';
|
||||
import { getEpisodeSources } from '../services/marketplace.js';
|
||||
import type { Earning, Episode, Podcast, Purchase, Reseller, User } from '../types.js';
|
||||
|
||||
function nowSecs(): number {
|
||||
return Math.floor(Date.now() / 1000);
|
||||
}
|
||||
|
||||
interface PurchaseContext {
|
||||
episodeId: string;
|
||||
buyerPubkey: string;
|
||||
source: 'producer' | string; // 'producer', or a reseller's pubkey
|
||||
amountSats: number;
|
||||
}
|
||||
|
||||
const purchaseSchema = z.object({
|
||||
source: z.string().min(1),
|
||||
});
|
||||
|
||||
const resellerSchema = z.object({
|
||||
download_url: z.string().url(),
|
||||
price_sats: z.number().int().positive(),
|
||||
});
|
||||
|
||||
const withdrawSchema = z.object({
|
||||
lud16: z.string().regex(/^[\w.+-]+@[\w.-]+$/, 'expected name@domain').optional(),
|
||||
});
|
||||
|
||||
export default async function marketplaceRoutes(app: FastifyInstance) {
|
||||
const { db, settings, publisher } = app.ctx;
|
||||
|
||||
const getEpisode = db.prepare('SELECT * FROM episodes WHERE id = ? AND podcast_id = ?');
|
||||
const getPodcast = db.prepare('SELECT * FROM podcasts WHERE id = ?');
|
||||
const getPurchase = db.prepare('SELECT * FROM purchases WHERE episode_id = ? AND buyer_pubkey = ?');
|
||||
const getQuote = db.prepare('SELECT * FROM cashu_quotes WHERE quote_id = ?');
|
||||
const getReseller = db.prepare(
|
||||
'SELECT * FROM resellers WHERE episode_id = ? AND pubkey = ? AND revoked_at IS NULL',
|
||||
);
|
||||
|
||||
function episodeAndPodcast(
|
||||
podcastId: string,
|
||||
episodeId: string,
|
||||
): { episode: Episode; podcast: Podcast } | null {
|
||||
const episode = getEpisode.get(episodeId, podcastId) as Episode | undefined;
|
||||
if (!episode) return null;
|
||||
const podcast = getPodcast.get(podcastId) as Podcast | undefined;
|
||||
if (!podcast) return null;
|
||||
return { episode, podcast };
|
||||
}
|
||||
|
||||
// Request an invoice to buy a priced episode, either straight from the producer or from a
|
||||
// certified reseller (source = their pubkey).
|
||||
app.post(
|
||||
'/api/podcasts/:id/episodes/:eid/purchase',
|
||||
{ preHandler: app.requireAuth },
|
||||
async (req, reply) => {
|
||||
const { id, eid } = req.params as { id: string; eid: string };
|
||||
const found = episodeAndPodcast(id, eid);
|
||||
if (!found) return reply.code(404).send({ error: 'episode not found' });
|
||||
const { episode, podcast } = found;
|
||||
if (!episode.price_sats) return reply.code(400).send({ error: 'this episode is free' });
|
||||
|
||||
const parsed = purchaseSchema.safeParse(req.body);
|
||||
if (!parsed.success) return reply.code(400).send({ error: parsed.error.message });
|
||||
|
||||
const buyerPubkey = req.userPubkey!;
|
||||
if (buyerPubkey === podcast.owner_pubkey) {
|
||||
return reply.code(400).send({ error: 'you own this podcast' });
|
||||
}
|
||||
if (getPurchase.get(eid, buyerPubkey)) {
|
||||
return reply.code(400).send({ error: 'already purchased' });
|
||||
}
|
||||
|
||||
const source = parsed.data.source;
|
||||
let amountSats: number;
|
||||
if (source === 'producer') {
|
||||
amountSats = episode.price_sats;
|
||||
} else {
|
||||
const reseller = getReseller.get(eid, source) as Reseller | undefined;
|
||||
if (!reseller) return reply.code(404).send({ error: 'reseller not found' });
|
||||
amountSats = reseller.price_sats;
|
||||
}
|
||||
|
||||
const cashu = app.ctx.cashu.client(settings.all().cashu_mint_url);
|
||||
let quote;
|
||||
try {
|
||||
quote = await cashu.requestMintQuote(amountSats);
|
||||
} catch (err) {
|
||||
if (err instanceof CashuUnreachableError) return reply.code(502).send({ error: err.message });
|
||||
throw err;
|
||||
}
|
||||
|
||||
const ctx: PurchaseContext = { episodeId: eid, buyerPubkey, source, amountSats };
|
||||
db.prepare(
|
||||
'INSERT INTO cashu_quotes (quote_id, purchase_ctx, state, created_at) VALUES (?, ?, ?, ?)',
|
||||
).run(quote.quoteId, JSON.stringify(ctx), 'pending', nowSecs());
|
||||
|
||||
return reply.code(201).send({ quoteId: quote.quoteId, invoice: quote.invoice, amountSats });
|
||||
},
|
||||
);
|
||||
|
||||
// Poll after paying the invoice: mints the ecash, writes the purchase + earnings ledger
|
||||
// rows, and signs/publishes the certified-download receipt. Idempotent — re-confirming an
|
||||
// already-settled quote just returns the existing purchase.
|
||||
app.post(
|
||||
'/api/podcasts/:id/episodes/:eid/purchase/:quoteId/confirm',
|
||||
{ preHandler: app.requireAuth },
|
||||
async (req, reply) => {
|
||||
const { id, eid, quoteId } = req.params as { id: string; eid: string; quoteId: string };
|
||||
const found = episodeAndPodcast(id, eid);
|
||||
if (!found) return reply.code(404).send({ error: 'episode not found' });
|
||||
const { episode, podcast } = found;
|
||||
|
||||
const quoteRow = getQuote.get(quoteId) as { purchase_ctx: string; state: string } | undefined;
|
||||
if (!quoteRow) return reply.code(404).send({ error: 'quote not found' });
|
||||
const ctx = JSON.parse(quoteRow.purchase_ctx) as PurchaseContext;
|
||||
if (ctx.episodeId !== eid || ctx.buyerPubkey !== req.userPubkey) {
|
||||
return reply.code(404).send({ error: 'quote not found' });
|
||||
}
|
||||
|
||||
if (quoteRow.state === 'settled') {
|
||||
return getPurchase.get(eid, ctx.buyerPubkey) as Purchase;
|
||||
}
|
||||
|
||||
// Resolve seller(s) + split now, at confirm time, rather than trusting whatever was
|
||||
// true when the quote was requested (a reseller could have been revoked meanwhile).
|
||||
let sellerPubkey: string;
|
||||
let generation: number;
|
||||
let parentPurchaseId: string | undefined;
|
||||
let earningsRows: Array<{ pubkey: string; amountSats: number }>;
|
||||
if (ctx.source === 'producer') {
|
||||
sellerPubkey = podcast.owner_pubkey;
|
||||
generation = 0;
|
||||
earningsRows = [{ pubkey: sellerPubkey, amountSats: ctx.amountSats }];
|
||||
} else {
|
||||
const reseller = getReseller.get(eid, ctx.source) as Reseller | undefined;
|
||||
const parentPurchase = getPurchase.get(eid, ctx.source) as Purchase | undefined;
|
||||
if (!reseller || !parentPurchase) {
|
||||
return reply.code(409).send({ error: 'reseller is no longer certified for this episode' });
|
||||
}
|
||||
sellerPubkey = ctx.source;
|
||||
generation = parentPurchase.generation + 1;
|
||||
parentPurchaseId = parentPurchase.id;
|
||||
const producerSats = Math.round((ctx.amountSats * podcast.resale_producer_share_pct) / 100);
|
||||
earningsRows = [
|
||||
{ pubkey: podcast.owner_pubkey, amountSats: producerSats },
|
||||
{ pubkey: sellerPubkey, amountSats: ctx.amountSats - producerSats },
|
||||
];
|
||||
}
|
||||
|
||||
const cashu = app.ctx.cashu.client(settings.all().cashu_mint_url);
|
||||
const paid = await cashu.isQuotePaid(quoteId);
|
||||
if (!paid) return reply.code(402).send({ error: 'invoice not paid yet' });
|
||||
|
||||
await cashu.mintAndStore(ctx.amountSats, quoteId);
|
||||
|
||||
const purchaseId = randomUUID();
|
||||
const receipt = await publisher.publishPurchaseReceipt(settings.all().relays, {
|
||||
purchaseId,
|
||||
episodeSha256: episode.sha256,
|
||||
buyerPubkey: ctx.buyerPubkey,
|
||||
sellerPubkey,
|
||||
amountSats: ctx.amountSats,
|
||||
generation,
|
||||
parentPurchaseId,
|
||||
});
|
||||
|
||||
const tx = db.transaction(() => {
|
||||
db.prepare(`
|
||||
INSERT INTO purchases (id, episode_id, buyer_pubkey, seller_pubkey, generation,
|
||||
amount_sats, receipt_event_id, receipt_json, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(purchaseId, eid, ctx.buyerPubkey, sellerPubkey, generation, ctx.amountSats,
|
||||
receipt.id, JSON.stringify(receipt), nowSecs());
|
||||
const insertEarning = db.prepare(
|
||||
'INSERT INTO earnings (id, purchase_id, pubkey, amount_sats) VALUES (?, ?, ?, ?)',
|
||||
);
|
||||
for (const e of earningsRows) insertEarning.run(randomUUID(), purchaseId, e.pubkey, e.amountSats);
|
||||
db.prepare("UPDATE cashu_quotes SET state = 'settled' WHERE quote_id = ?").run(quoteId);
|
||||
});
|
||||
tx();
|
||||
|
||||
return reply.code(201).send(getPurchase.get(eid, ctx.buyerPubkey) as Purchase);
|
||||
},
|
||||
);
|
||||
|
||||
// Producer + certified resellers for an episode, with a naive reputation signal (how many
|
||||
// sales that seller has made). Public and deliberately unauthenticated, including real
|
||||
// download URLs — this is an open catalog meant to be crawlable (by other podsteadr servers,
|
||||
// scripts, whoever), not a gate. Same data also drives the RSS `podsteadr:source` tags
|
||||
// (see services/rss.ts) so the catalog is discoverable via the feed itself, not just this
|
||||
// JSON endpoint.
|
||||
app.get('/api/podcasts/:id/episodes/:eid/sources', async (req, reply) => {
|
||||
const { id, eid } = req.params as { id: string; eid: string };
|
||||
const found = episodeAndPodcast(id, eid);
|
||||
if (!found) return reply.code(404).send({ error: 'episode not found' });
|
||||
const sources = getEpisodeSources(db, found.episode, found.podcast);
|
||||
if (!sources) return reply.code(400).send({ error: 'this episode is free' });
|
||||
|
||||
return {
|
||||
episode: { title: found.episode.title, description: found.episode.description },
|
||||
podcast: { title: found.podcast.title },
|
||||
...sources,
|
||||
};
|
||||
});
|
||||
|
||||
// A buyer who holds a receipt for this episode can certify themselves as a reseller,
|
||||
// pointing at their own Blossom-compatible host for the same blob.
|
||||
app.post(
|
||||
'/api/podcasts/:id/episodes/:eid/resellers',
|
||||
{ preHandler: app.requireAuth },
|
||||
async (req, reply) => {
|
||||
const { id, eid } = req.params as { id: string; eid: string };
|
||||
const found = episodeAndPodcast(id, eid);
|
||||
if (!found) return reply.code(404).send({ error: 'episode not found' });
|
||||
const pubkey = req.userPubkey!;
|
||||
if (!getPurchase.get(eid, pubkey)) {
|
||||
return reply.code(403).send({ error: 'you must have purchased this episode to resell it' });
|
||||
}
|
||||
const parsed = resellerSchema.safeParse(req.body);
|
||||
if (!parsed.success) return reply.code(400).send({ error: parsed.error.message });
|
||||
const { download_url, price_sats } = parsed.data;
|
||||
|
||||
let blob;
|
||||
try {
|
||||
blob = await checkBlob(download_url, found.episode.sha256);
|
||||
} catch (err) {
|
||||
if (err instanceof BlossomUnreachableError) return reply.code(502).send({ error: err.message });
|
||||
throw err;
|
||||
}
|
||||
if (!blob.exists) {
|
||||
return reply.code(422).send({ error: `blob ${found.episode.sha256} not found on ${download_url}` });
|
||||
}
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO resellers (episode_id, pubkey, download_url, price_sats, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(episode_id, pubkey) DO UPDATE SET
|
||||
download_url = excluded.download_url, price_sats = excluded.price_sats, revoked_at = NULL
|
||||
`).run(eid, pubkey, download_url, price_sats, nowSecs());
|
||||
|
||||
return reply.code(201).send(
|
||||
db.prepare('SELECT * FROM resellers WHERE episode_id = ? AND pubkey = ?').get(eid, pubkey) as Reseller,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
app.delete(
|
||||
'/api/podcasts/:id/episodes/:eid/resellers/me',
|
||||
{ preHandler: app.requireAuth },
|
||||
async (req, reply) => {
|
||||
const { id, eid } = req.params as { id: string; eid: string };
|
||||
if (!episodeAndPodcast(id, eid)) return reply.code(404).send({ error: 'episode not found' });
|
||||
db.prepare('UPDATE resellers SET revoked_at = ? WHERE episode_id = ? AND pubkey = ?')
|
||||
.run(nowSecs(), eid, req.userPubkey!);
|
||||
return { ok: true };
|
||||
},
|
||||
);
|
||||
|
||||
// The paywall gate: hands back the real Blossom URL only to whoever is allowed to have it.
|
||||
app.get(
|
||||
'/api/podcasts/:id/episodes/:eid/download-url',
|
||||
{ preHandler: app.requireAuth },
|
||||
async (req, reply) => {
|
||||
const { id, eid } = req.params as { id: string; eid: string };
|
||||
const found = episodeAndPodcast(id, eid);
|
||||
if (!found) return reply.code(404).send({ error: 'episode not found' });
|
||||
const { episode, podcast } = found;
|
||||
|
||||
if (
|
||||
!episode.price_sats ||
|
||||
req.userPubkey === podcast.owner_pubkey ||
|
||||
getPurchase.get(eid, req.userPubkey!)
|
||||
) {
|
||||
return { url: episode.enclosure_url };
|
||||
}
|
||||
return reply.code(402).send({ error: 'payment required', price_sats: episode.price_sats });
|
||||
},
|
||||
);
|
||||
|
||||
app.get('/api/earnings', { preHandler: app.requireAuth }, async (req) => {
|
||||
const rows = db
|
||||
.prepare('SELECT * FROM earnings WHERE pubkey = ? ORDER BY id')
|
||||
.all(req.userPubkey) as Earning[];
|
||||
const unwithdrawnSats = rows
|
||||
.filter((e) => e.withdrawn_at == null)
|
||||
.reduce((sum, e) => sum + e.amount_sats, 0);
|
||||
return { unwithdrawn_sats: unwithdrawnSats, earnings: rows };
|
||||
});
|
||||
|
||||
app.post('/api/earnings/withdraw', { preHandler: app.requireAuth }, async (req, reply) => {
|
||||
const parsed = withdrawSchema.safeParse(req.body ?? {});
|
||||
if (!parsed.success) return reply.code(400).send({ error: parsed.error.message });
|
||||
|
||||
const pubkey = req.userPubkey!;
|
||||
const user = db.prepare('SELECT * FROM users WHERE pubkey = ?').get(pubkey) as User | undefined;
|
||||
const lud16 = parsed.data.lud16 ?? user?.lud16 ?? null;
|
||||
if (!lud16) {
|
||||
return reply.code(400).send({ error: 'no lightning address on file — pass lud16 to withdraw to' });
|
||||
}
|
||||
|
||||
const unwithdrawn = db
|
||||
.prepare('SELECT * FROM earnings WHERE pubkey = ? AND withdrawn_at IS NULL')
|
||||
.all(pubkey) as Earning[];
|
||||
const totalSats = unwithdrawn.reduce((sum, e) => sum + e.amount_sats, 0);
|
||||
if (totalSats <= 0) return reply.code(400).send({ error: 'nothing to withdraw' });
|
||||
|
||||
const cashu = app.ctx.cashu.client(settings.all().cashu_mint_url);
|
||||
let result;
|
||||
try {
|
||||
result = await cashu.payout(lud16, totalSats);
|
||||
} catch (err) {
|
||||
if (err instanceof CashuUnreachableError || err instanceof CashuInsufficientFundsError) {
|
||||
return reply.code(502).send({ error: err.message });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
const tx = db.transaction(() => {
|
||||
const markWithdrawn = db.prepare('UPDATE earnings SET withdrawn_at = ? WHERE id = ?');
|
||||
for (const e of unwithdrawn) markWithdrawn.run(nowSecs(), e.id);
|
||||
if (parsed.data.lud16) db.prepare('UPDATE users SET lud16 = ? WHERE pubkey = ?').run(parsed.data.lud16, pubkey);
|
||||
});
|
||||
tx();
|
||||
|
||||
return { paid_sats: totalSats, preimage: result.preimage };
|
||||
});
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { randomUUID } from 'node:crypto';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { podcastGuidForFeedUrl } from '../services/rss.js';
|
||||
import { checkBlob, BlossomUnreachableError } from '../services/blossom.js';
|
||||
import { checkBlob, BlossomUnreachableError, extForMime } from '../services/blossom.js';
|
||||
import type { Episode, Podcast } from '../types.js';
|
||||
|
||||
function nowSecs(): number {
|
||||
@@ -20,6 +20,7 @@ const podcastSchema = z.object({
|
||||
lightning_address: z.string().regex(/^[\w.+-]+@[\w.-]+$/, 'expected name@domain').nullish(),
|
||||
keysend_node: z.string().regex(/^[0-9a-f]{66}$/i, 'expected 33-byte hex node pubkey').nullish(),
|
||||
value_suggested: z.string().max(20).nullish(),
|
||||
resale_producer_share_pct: z.number().int().min(0).max(100).optional(),
|
||||
});
|
||||
|
||||
const episodeSchema = z.object({
|
||||
@@ -33,6 +34,7 @@ const episodeSchema = z.object({
|
||||
season: z.number().int().positive().nullish(),
|
||||
episode_no: z.number().int().positive().nullish(),
|
||||
pub_date: z.number().int().positive().optional(),
|
||||
price_sats: z.number().int().positive().nullish(),
|
||||
});
|
||||
|
||||
export default async function podcastRoutes(app: FastifyInstance) {
|
||||
@@ -95,11 +97,13 @@ export default async function podcastRoutes(app: FastifyInstance) {
|
||||
const d = { ...p, ...parsed.data, explicit: (parsed.data.explicit ?? !!p.explicit) ? 1 : 0 };
|
||||
db.prepare(`
|
||||
UPDATE podcasts SET title=?, description=?, author=?, image_url=?, language=?, category=?,
|
||||
explicit=?, lightning_address=?, keysend_node=?, value_suggested=?, updated_at=?
|
||||
explicit=?, lightning_address=?, keysend_node=?, value_suggested=?,
|
||||
resale_producer_share_pct=?, updated_at=?
|
||||
WHERE id=?
|
||||
`).run(
|
||||
d.title, d.description, d.author, d.image_url ?? null, d.language, d.category, d.explicit,
|
||||
d.lightning_address ?? null, d.keysend_node ?? null, d.value_suggested ?? null, nowSecs(), id,
|
||||
d.lightning_address ?? null, d.keysend_node ?? null, d.value_suggested ?? null,
|
||||
d.resale_producer_share_pct, nowSecs(), id,
|
||||
);
|
||||
return getPodcast.get(id) as Podcast;
|
||||
});
|
||||
@@ -138,17 +142,17 @@ export default async function podcastRoutes(app: FastifyInstance) {
|
||||
return reply.code(422).send({ error: `blob size mismatch (server has ${blob.size}, claimed ${d.size})` });
|
||||
}
|
||||
|
||||
const ext = d.mime === 'audio/mpeg' ? 'mp3' : d.mime === 'audio/mp4' ? 'm4a' : 'mp4';
|
||||
const enclosureUrl = `${blossomUrl.replace(/\/+$/, '')}/${d.sha256}.${ext}`;
|
||||
const enclosureUrl = `${blossomUrl.replace(/\/+$/, '')}/${d.sha256}.${extForMime(d.mime)}`;
|
||||
const eid = randomUUID();
|
||||
db.prepare(`
|
||||
INSERT INTO episodes (id, podcast_id, title, description, sha256, enclosure_url,
|
||||
enclosure_length, enclosure_type, duration_secs, season, episode_no, source, pub_date, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'upload', ?, ?)
|
||||
enclosure_length, enclosure_type, duration_secs, season, episode_no, source, pub_date,
|
||||
created_at, price_sats)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'upload', ?, ?, ?)
|
||||
`).run(
|
||||
eid, id, d.title, d.description, d.sha256, enclosureUrl, d.size, d.mime,
|
||||
d.duration_secs ?? null, d.season ?? null, d.episode_no ?? null,
|
||||
d.pub_date ?? nowSecs(), nowSecs(),
|
||||
d.pub_date ?? nowSecs(), nowSecs(), d.price_sats ?? null,
|
||||
);
|
||||
db.prepare('UPDATE podcasts SET updated_at = ? WHERE id = ?').run(nowSecs(), id);
|
||||
return reply.code(201).send(getEpisode.get(eid, id) as Episode);
|
||||
@@ -163,10 +167,11 @@ export default async function podcastRoutes(app: FastifyInstance) {
|
||||
if (!parsed.success) return reply.code(400).send({ error: parsed.error.message });
|
||||
const d = { ...episode, ...parsed.data };
|
||||
db.prepare(`
|
||||
UPDATE episodes SET title=?, description=?, duration_secs=?, season=?, episode_no=?, pub_date=?
|
||||
UPDATE episodes SET title=?, description=?, duration_secs=?, season=?, episode_no=?,
|
||||
pub_date=?, price_sats=?
|
||||
WHERE id=?
|
||||
`).run(d.title, d.description, d.duration_secs ?? null, d.season ?? null,
|
||||
d.episode_no ?? null, d.pub_date, eid);
|
||||
d.episode_no ?? null, d.pub_date, d.price_sats ?? null, eid);
|
||||
return getEpisode.get(eid, id) as Episode;
|
||||
});
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ const updateSchema = z.object({
|
||||
blossom_url: z.string().url().optional(),
|
||||
relays: z.array(z.string().regex(/^wss?:\/\//)).optional(),
|
||||
public_url: z.string().url().optional(),
|
||||
cashu_mint_url: z.string().url().optional(),
|
||||
});
|
||||
|
||||
export default async function settingsRoutes(app: FastifyInstance) {
|
||||
@@ -32,10 +33,11 @@ export default async function settingsRoutes(app: FastifyInstance) {
|
||||
}
|
||||
const parsed = updateSchema.safeParse(req.body);
|
||||
if (!parsed.success) return reply.code(400).send({ error: parsed.error.message });
|
||||
const { blossom_url, relays, public_url } = parsed.data;
|
||||
const { blossom_url, relays, public_url, cashu_mint_url } = parsed.data;
|
||||
if (blossom_url !== undefined) settings.set('blossom_url', blossom_url);
|
||||
if (relays !== undefined) settings.set('relays', JSON.stringify(relays));
|
||||
if (public_url !== undefined) settings.set('public_url', public_url);
|
||||
if (cashu_mint_url !== undefined) settings.set('cashu_mint_url', cashu_mint_url);
|
||||
return settings.all();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -9,6 +9,11 @@ export interface BlobCheck {
|
||||
|
||||
export class BlossomUnreachableError extends Error {}
|
||||
|
||||
/** File extension blossom stores/serves blobs under, derived from the episode's mime type. */
|
||||
export function extForMime(mime: string): string {
|
||||
return mime === 'audio/mpeg' ? 'mp3' : mime === 'audio/mp4' ? 'm4a' : 'mp4';
|
||||
}
|
||||
|
||||
/** HEAD a blob on a blossom server to confirm it exists and matches the claimed size. */
|
||||
export async function checkBlob(blossomUrl: string, sha256: string): Promise<BlobCheck> {
|
||||
let res: Response;
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { Mint, Wallet, MintQuoteState, deserializeProofs, type Proof } from '@cashu/cashu-ts';
|
||||
import type { DB } from '../db/database.js';
|
||||
import type { CashuProofRow } from '../types.js';
|
||||
|
||||
function nowSecs(): number {
|
||||
return Math.floor(Date.now() / 1000);
|
||||
}
|
||||
|
||||
export class CashuUnreachableError extends Error {}
|
||||
export class CashuInsufficientFundsError extends Error {}
|
||||
|
||||
export interface MintQuote {
|
||||
quoteId: string;
|
||||
invoice: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps `@cashu/cashu-ts`'s `Mint`/`Wallet` classes for a single configured mint. The server
|
||||
* is the Cashu wallet holder here (self-hosted single-operator trust model, same tier as the
|
||||
* session/stream-key custody it already does) — buyers pay a plain bolt11 invoice with any
|
||||
* Lightning wallet, never touching Cashu themselves. Proofs are custodied as plain JSON rows
|
||||
* in `cashu_proofs` (not encoded Cashu tokens — there's no need for the portable bech32/base64
|
||||
* token format since these proofs are only ever spent by this same server, never handed out).
|
||||
*/
|
||||
export class CashuMintClient {
|
||||
private wallet: Wallet;
|
||||
private loaded = false;
|
||||
|
||||
constructor(private mintUrl: string, private db: DB) {
|
||||
this.wallet = new Wallet(new Mint(mintUrl));
|
||||
}
|
||||
|
||||
private async ensureLoaded(): Promise<void> {
|
||||
if (this.loaded) return;
|
||||
try {
|
||||
await this.wallet.loadMint();
|
||||
} catch (err) {
|
||||
throw new CashuUnreachableError(`cashu mint ${this.mintUrl} unreachable: ${(err as Error).message}`);
|
||||
}
|
||||
this.loaded = true;
|
||||
}
|
||||
|
||||
/** NUT-04: request a bolt11 invoice for `amountSats`. Caller stores the quote id themselves. */
|
||||
async requestMintQuote(amountSats: number): Promise<MintQuote> {
|
||||
await this.ensureLoaded();
|
||||
const quote = await this.wallet.createMintQuoteBolt11(amountSats);
|
||||
return { quoteId: quote.quote, invoice: quote.request };
|
||||
}
|
||||
|
||||
/** Whether the mint has seen the invoice paid yet. */
|
||||
async isQuotePaid(quoteId: string): Promise<boolean> {
|
||||
await this.ensureLoaded();
|
||||
const quote = await this.wallet.checkMintQuoteBolt11(quoteId);
|
||||
return quote.state === MintQuoteState.PAID || quote.state === MintQuoteState.ISSUED;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mints proofs for a paid quote and custodies them as a row in `cashu_proofs`. Returns the
|
||||
* row id. Throws if the quote isn't paid yet.
|
||||
*/
|
||||
async mintAndStore(amountSats: number, quoteId: string): Promise<string> {
|
||||
await this.ensureLoaded();
|
||||
const proofs = await this.wallet.mintProofsBolt11(amountSats, quoteId);
|
||||
return this.storeProofs(proofs, amountSats);
|
||||
}
|
||||
|
||||
private storeProofs(proofs: Proof[], amountSats: number): string {
|
||||
const id = randomUUID();
|
||||
this.db
|
||||
.prepare('INSERT INTO cashu_proofs (id, proofs_json, amount_sats, created_at) VALUES (?, ?, ?, ?)')
|
||||
.run(id, JSON.stringify(proofs), amountSats, nowSecs());
|
||||
return id;
|
||||
}
|
||||
|
||||
/** Sum of unspent custodied proofs — the server's total held Cashu balance. */
|
||||
totalHeldSats(): number {
|
||||
const row = this.db
|
||||
.prepare('SELECT COALESCE(SUM(amount_sats), 0) AS total FROM cashu_proofs WHERE spent_at IS NULL')
|
||||
.get() as { total: number };
|
||||
return row.total;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a lud16 (lightning address) to a bolt11 invoice for `amountSats` via LNURL-pay
|
||||
* (LUD-16 / LUD-06), melts enough held proofs to pay it, and returns the payment preimage.
|
||||
* Any unspent overpaid-fee-reserve change proofs are re-stored for future payouts.
|
||||
*/
|
||||
async payout(lud16: string, amountSats: number): Promise<{ preimage: string | null }> {
|
||||
await this.ensureLoaded();
|
||||
const invoice = await this.resolveLnurlpInvoice(lud16, amountSats);
|
||||
const meltQuote = await this.wallet.createMeltQuoteBolt11(invoice);
|
||||
const needed = meltQuote.amount.add(meltQuote.fee_reserve).toNumber();
|
||||
|
||||
const unspent = this.db
|
||||
.prepare('SELECT * FROM cashu_proofs WHERE spent_at IS NULL ORDER BY created_at')
|
||||
.all() as CashuProofRow[];
|
||||
const rows: CashuProofRow[] = [];
|
||||
let sum = 0;
|
||||
for (const row of unspent) {
|
||||
rows.push(row);
|
||||
sum += row.amount_sats;
|
||||
if (sum >= needed) break;
|
||||
}
|
||||
if (sum < needed) {
|
||||
throw new CashuInsufficientFundsError(
|
||||
`held ${sum} sats, need ${needed} sats to pay out ${amountSats} to ${lud16}`,
|
||||
);
|
||||
}
|
||||
|
||||
const proofs = rows.flatMap((r) => deserializeProofs(r.proofs_json));
|
||||
const result = await this.wallet.meltProofsBolt11(meltQuote, proofs);
|
||||
|
||||
const spendTx = this.db.transaction(() => {
|
||||
const markSpent = this.db.prepare('UPDATE cashu_proofs SET spent_at = ? WHERE id = ?');
|
||||
for (const row of rows) markSpent.run(nowSecs(), row.id);
|
||||
if (result.change.length > 0) {
|
||||
const changeSats = sum - needed;
|
||||
if (changeSats > 0) this.storeProofs(result.change, changeSats);
|
||||
}
|
||||
});
|
||||
spendTx();
|
||||
|
||||
return { preimage: result.quote.payment_preimage };
|
||||
}
|
||||
|
||||
private async resolveLnurlpInvoice(lud16: string, amountSats: number): Promise<string> {
|
||||
const [name, domain] = lud16.split('@');
|
||||
if (!name || !domain) throw new Error(`invalid lightning address: ${lud16}`);
|
||||
const wellKnown = `https://${domain}/.well-known/lnurlp/${name}`;
|
||||
const meta = await fetch(wellKnown).then((r) => {
|
||||
if (!r.ok) throw new Error(`lnurlp lookup failed for ${lud16}: ${r.status}`);
|
||||
return r.json() as Promise<{ callback: string; minSendable: number; maxSendable: number }>;
|
||||
});
|
||||
const amountMsat = amountSats * 1000;
|
||||
if (amountMsat < meta.minSendable || amountMsat > meta.maxSendable) {
|
||||
throw new Error(`${amountSats} sats is outside ${lud16}'s payable range`);
|
||||
}
|
||||
const sep = meta.callback.includes('?') ? '&' : '?';
|
||||
const invoiceRes = await fetch(`${meta.callback}${sep}amount=${amountMsat}`).then((r) => {
|
||||
if (!r.ok) throw new Error(`lnurlp callback failed for ${lud16}: ${r.status}`);
|
||||
return r.json() as Promise<{ pr: string }>;
|
||||
});
|
||||
return invoiceRes.pr;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The configured mint URL can change at runtime (admin settings), so routes resolve it fresh
|
||||
* from `SettingsService.all().cashu_mint_url` on every call rather than binding to one mint at
|
||||
* app-build time — this pool just avoids re-creating (and re-`loadMint`-ing) a client for the
|
||||
* common case where the URL hasn't changed, mirroring how `blossomServerSideUrl` resolves the
|
||||
* effective blossom URL per-call instead of caching it.
|
||||
*/
|
||||
export class CashuClientPool {
|
||||
private clients = new Map<string, CashuMintClient>();
|
||||
|
||||
constructor(private db: DB) {}
|
||||
|
||||
client(mintUrl: string): CashuMintClient {
|
||||
let client = this.clients.get(mintUrl);
|
||||
if (!client) {
|
||||
client = new CashuMintClient(mintUrl, this.db);
|
||||
this.clients.set(mintUrl, client);
|
||||
}
|
||||
return client;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { DB } from '../db/database.js';
|
||||
import { extForMime } from './blossom.js';
|
||||
import type { Episode, Podcast, Reseller } from '../types.js';
|
||||
|
||||
export interface Source {
|
||||
type: 'producer' | 'reseller';
|
||||
pubkey: string;
|
||||
price_sats: number;
|
||||
url: string;
|
||||
sales_count: number;
|
||||
}
|
||||
|
||||
export interface EpisodeSources {
|
||||
producer: Source;
|
||||
resellers: Source[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Producer + certified resellers for a priced episode, each with a naive reputation signal
|
||||
* (how many sales that seller has made). Shared by the public `/sources` API and the RSS feed —
|
||||
* the whole point is that this is meant to be openly discoverable, not gated behind auth.
|
||||
*/
|
||||
export function getEpisodeSources(db: DB, episode: Episode, podcast: Podcast): EpisodeSources | null {
|
||||
if (!episode.price_sats) return null;
|
||||
|
||||
const countSales = db.prepare('SELECT COUNT(*) AS c FROM purchases WHERE episode_id = ? AND seller_pubkey = ?');
|
||||
const salesCount = (pubkey: string): number => (countSales.get(episode.id, pubkey) as { c: number }).c;
|
||||
|
||||
const resellers = db
|
||||
.prepare('SELECT * FROM resellers WHERE episode_id = ? AND revoked_at IS NULL ORDER BY created_at')
|
||||
.all(episode.id) as Reseller[];
|
||||
|
||||
return {
|
||||
producer: {
|
||||
type: 'producer',
|
||||
pubkey: podcast.owner_pubkey,
|
||||
price_sats: episode.price_sats,
|
||||
url: episode.enclosure_url,
|
||||
sales_count: salesCount(podcast.owner_pubkey),
|
||||
},
|
||||
resellers: resellers.map((r) => ({
|
||||
type: 'reseller',
|
||||
pubkey: r.pubkey,
|
||||
price_sats: r.price_sats,
|
||||
url: `${r.download_url.replace(/\/+$/, '')}/${episode.sha256}.${extForMime(episode.enclosure_type)}`,
|
||||
sales_count: salesCount(r.pubkey),
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -48,6 +48,41 @@ export function buildLiveEvent(secretKey: Uint8Array, input: LiveEventInput): Ev
|
||||
);
|
||||
}
|
||||
|
||||
/** Provisional app-specific kind for purchase receipts — not (yet) a standardized NIP. */
|
||||
export const PURCHASE_RECEIPT_KIND = 30356;
|
||||
|
||||
export interface PurchaseReceiptInput {
|
||||
purchaseId: string;
|
||||
episodeSha256: string;
|
||||
buyerPubkey: string;
|
||||
sellerPubkey: string;
|
||||
amountSats: number;
|
||||
generation: number;
|
||||
parentPurchaseId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the purchase-receipt event, signed by the server's key (the same key that signs
|
||||
* 30311s) — producers/resellers authenticate via NIP-07 in the browser, so their private key
|
||||
* never reaches the server and can't sign this itself. The server instead attests the sale,
|
||||
* with both buyer and seller `p`-tagged.
|
||||
*/
|
||||
export function buildPurchaseReceipt(secretKey: Uint8Array, input: PurchaseReceiptInput): Event {
|
||||
const tags: string[][] = [
|
||||
['d', input.purchaseId],
|
||||
['x', input.episodeSha256],
|
||||
['p', input.buyerPubkey, '', 'buyer'],
|
||||
['p', input.sellerPubkey, '', 'seller'],
|
||||
['amount', String(input.amountSats)],
|
||||
['generation', String(input.generation)],
|
||||
];
|
||||
if (input.parentPurchaseId) tags.push(['e', input.parentPurchaseId]);
|
||||
return finalizeEvent(
|
||||
{ kind: PURCHASE_RECEIPT_KIND, created_at: Math.floor(Date.now() / 1000), content: '', tags },
|
||||
secretKey,
|
||||
);
|
||||
}
|
||||
|
||||
export class NostrPublisher {
|
||||
private pool = new SimplePool();
|
||||
|
||||
@@ -71,6 +106,24 @@ export class NostrPublisher {
|
||||
return event;
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish a purchase receipt. Like `publishLiveEvent`, this never throws on relay failure —
|
||||
* the `purchases.receipt_json` DB row is the authoritative record; relay publication is only
|
||||
* for external visibility/verifiability.
|
||||
*/
|
||||
async publishPurchaseReceipt(relays: string[], input: PurchaseReceiptInput): Promise<Event> {
|
||||
const event = buildPurchaseReceipt(this.secretKey, input);
|
||||
if (relays.length === 0) return event;
|
||||
const results = await Promise.allSettled(this.pool.publish(relays, event));
|
||||
const ok = results.filter((r) => r.status === 'fulfilled').length;
|
||||
if (ok === 0) {
|
||||
this.log.warn(`purchase receipt ${input.purchaseId}: no relay accepted the event`);
|
||||
} else {
|
||||
this.log.info(`purchase receipt ${input.purchaseId}: accepted by ${ok}/${relays.length} relays`);
|
||||
}
|
||||
return event;
|
||||
}
|
||||
|
||||
close(relays: string[]): void {
|
||||
this.pool.close(relays);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildFeedXml, podcastGuidForFeedUrl, xmlEscape } from './rss.js';
|
||||
import { buildCatalogOpml, buildFeedXml, podcastGuidForFeedUrl, xmlEscape } from './rss.js';
|
||||
import type { EpisodeSources } from './marketplace.js';
|
||||
import type { Episode, Podcast } from '../types.js';
|
||||
|
||||
const podcast: Podcast = {
|
||||
@@ -87,4 +88,55 @@ describe('buildFeedXml', () => {
|
||||
expect(xml).toContain('<itunes:duration>01:02:05</itunes:duration>');
|
||||
expect(xml).toContain(`<guid isPermaLink="false">${'a'.repeat(64)}</guid>`);
|
||||
});
|
||||
|
||||
it('gates a priced episode behind a landing page instead of the raw blossom url', () => {
|
||||
const paid: Episode = { ...episode, id: 'e2', price_sats: 500 };
|
||||
const lockedXml = buildFeedXml(podcast, [paid], { publicUrl: 'http://host:8095' });
|
||||
expect(lockedXml).not.toContain(episode.enclosure_url);
|
||||
expect(lockedXml).toContain(`<enclosure url="http://host:8095/podcasts/p1/episodes/e2" length="12345" type="text/html"/>`);
|
||||
expect(lockedXml).toContain('500 sats');
|
||||
});
|
||||
|
||||
it('declares the podsteadr namespace and lists sources for a priced episode', () => {
|
||||
const paid: Episode = { ...episode, id: 'e3', price_sats: 500 };
|
||||
const sources: EpisodeSources = {
|
||||
producer: { type: 'producer', pubkey: 'deadbeef', price_sats: 500, url: 'http://blossom/x.mp4', sales_count: 3 },
|
||||
resellers: [
|
||||
{ type: 'reseller', pubkey: 'cafef00d', price_sats: 300, url: 'http://mirror/x.mp4', sales_count: 1 },
|
||||
],
|
||||
};
|
||||
const xmlWithSources = buildFeedXml(podcast, [paid], {
|
||||
publicUrl: 'http://host:8095',
|
||||
sourcesByEpisode: new Map([['e3', sources]]),
|
||||
});
|
||||
expect(xmlWithSources).toContain('xmlns:podsteadr="https://podsteadr.dev/ns/1.0"');
|
||||
expect(xmlWithSources).toContain(
|
||||
'<podsteadr:source type="producer" pubkey="deadbeef" price="500" sales="3" url="http://blossom/x.mp4"/>',
|
||||
);
|
||||
expect(xmlWithSources).toContain(
|
||||
'<podsteadr:source type="reseller" pubkey="cafef00d" price="300" sales="1" url="http://mirror/x.mp4"/>',
|
||||
);
|
||||
});
|
||||
|
||||
it('omits source tags for a priced episode when none were precomputed', () => {
|
||||
const paid: Episode = { ...episode, id: 'e4', price_sats: 500 };
|
||||
const xmlNoSources = buildFeedXml(podcast, [paid], { publicUrl: 'http://host:8095' });
|
||||
expect(xmlNoSources).not.toContain('<podsteadr:source');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildCatalogOpml', () => {
|
||||
it('lists one outline per podcast with its feed url', () => {
|
||||
const p2: Podcast = { ...podcast, id: 'p2', title: 'Second & Show' };
|
||||
const opml = buildCatalogOpml([podcast, p2], { publicUrl: 'http://host:8095' });
|
||||
expect(opml).toContain('<opml version="2.0">');
|
||||
expect(opml).toContain('<outline text="Test & Show" type="rss" xmlUrl="http://host:8095/feeds/p1/feed.xml"/>');
|
||||
expect(opml).toContain('<outline text="Second & Show" type="rss" xmlUrl="http://host:8095/feeds/p2/feed.xml"/>');
|
||||
});
|
||||
|
||||
it('produces valid (parseable) XML with no podcasts', () => {
|
||||
const opml = buildCatalogOpml([], { publicUrl: 'http://host:8095' });
|
||||
expect(opml).toContain('<body>');
|
||||
expect(opml).toContain('</body>');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import type { Episode, Podcast } from '../types.js';
|
||||
import type { EpisodeSources } from './marketplace.js';
|
||||
|
||||
/** Namespace UUID for podcast:guid, fixed by the podcast-namespace spec. */
|
||||
const PODCAST_GUID_NAMESPACE = 'ead4c236-bf58-58c6-a2c6-a6b28d128cb6';
|
||||
@@ -64,6 +65,14 @@ function valueBlock(p: Podcast): string {
|
||||
|
||||
export interface FeedContext {
|
||||
publicUrl: string; // e.g. http://host:8095
|
||||
// Precomputed producer+reseller options for priced episodes (services/marketplace.ts) —
|
||||
// rendered as <podsteadr:source> tags so the catalog is discoverable straight from the RSS
|
||||
// feed by anyone/anything (another podsteadr server, a script) without a separate API call.
|
||||
sourcesByEpisode?: Map<string, EpisodeSources>;
|
||||
}
|
||||
|
||||
function sourceTag(s: EpisodeSources['producer']): string {
|
||||
return ` <podsteadr:source type="${s.type}" pubkey="${xmlEscape(s.pubkey)}" price="${s.price_sats}" sales="${s.sales_count}" url="${xmlEscape(s.url)}"/>`;
|
||||
}
|
||||
|
||||
export function buildFeedXml(podcast: Podcast, episodes: Episode[], ctx: FeedContext): string {
|
||||
@@ -72,14 +81,29 @@ export function buildFeedXml(podcast: Podcast, episodes: Episode[], ctx: FeedCon
|
||||
const lastPub = episodes[0]?.pub_date ?? podcast.updated_at;
|
||||
|
||||
const items = episodes.map((e) => {
|
||||
// Priced episodes never expose the raw (unauthenticated) Blossom URL as the primary
|
||||
// <enclosure> — a plain podcast app would otherwise silently play/download it with no
|
||||
// indication it's paid. Point at the buy/info page instead; podsteadr-aware clients get
|
||||
// the real producer/reseller URLs from <podsteadr:source> below.
|
||||
const locked = !!e.price_sats;
|
||||
const enclosureUrl = locked ? `${ctx.publicUrl}/podcasts/${podcast.id}/episodes/${e.id}` : e.enclosure_url;
|
||||
const enclosureType = locked ? 'text/html' : e.enclosure_type;
|
||||
|
||||
const lines = [
|
||||
' <item>',
|
||||
` <title>${xmlEscape(e.title)}</title>`,
|
||||
` <description>${xmlEscape(e.description)}</description>`,
|
||||
` <description>${xmlEscape(locked ? `${e.description} (paid episode — ${e.price_sats} sats)` : e.description)}</description>`,
|
||||
` <guid isPermaLink="false">${xmlEscape(e.sha256)}</guid>`,
|
||||
` <pubDate>${rfc2822(e.pub_date)}</pubDate>`,
|
||||
` <enclosure url="${xmlEscape(e.enclosure_url)}" length="${e.enclosure_length}" type="${xmlEscape(e.enclosure_type)}"/>`,
|
||||
` <enclosure url="${xmlEscape(enclosureUrl)}" length="${e.enclosure_length}" type="${xmlEscape(enclosureType)}"/>`,
|
||||
];
|
||||
if (locked) {
|
||||
const sources = ctx.sourcesByEpisode?.get(e.id);
|
||||
if (sources) {
|
||||
lines.push(sourceTag(sources.producer));
|
||||
for (const r of sources.resellers) lines.push(sourceTag(r));
|
||||
}
|
||||
}
|
||||
if (e.duration_secs != null) lines.push(` <itunes:duration>${itunesDuration(e.duration_secs)}</itunes:duration>`);
|
||||
if (e.season != null) lines.push(` <podcast:season>${e.season}</podcast:season>`);
|
||||
if (e.episode_no != null) lines.push(` <podcast:episode>${e.episode_no}</podcast:episode>`);
|
||||
@@ -119,7 +143,8 @@ export function buildFeedXml(podcast: Podcast, episodes: Episode[], ctx: FeedCon
|
||||
'<rss version="2.0"',
|
||||
' xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd"',
|
||||
' xmlns:podcast="https://podcastindex.org/namespace/1.0"',
|
||||
' xmlns:atom="http://www.w3.org/2005/Atom">',
|
||||
' xmlns:atom="http://www.w3.org/2005/Atom"',
|
||||
' xmlns:podsteadr="https://podsteadr.dev/ns/1.0">',
|
||||
' <channel>',
|
||||
...channel,
|
||||
...items,
|
||||
@@ -128,3 +153,31 @@ export function buildFeedXml(podcast: Podcast, episodes: Episode[], ctx: FeedCon
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export interface CatalogContext {
|
||||
publicUrl: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every podcast this instance hosts, as an OPML feed directory — the standard format for
|
||||
* listing feeds, so any server or crawler (podsteadr-aware or not) can discover the full
|
||||
* catalog in one request and then read each feed for pricing/sources.
|
||||
*/
|
||||
export function buildCatalogOpml(podcasts: Podcast[], ctx: CatalogContext): string {
|
||||
const outlines = podcasts.map((p) => {
|
||||
const feedUrl = `${ctx.publicUrl}/feeds/${p.id}/feed.xml`;
|
||||
return ` <outline text="${xmlEscape(p.title)}" type="rss" xmlUrl="${xmlEscape(feedUrl)}"/>`;
|
||||
});
|
||||
return [
|
||||
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||
'<opml version="2.0">',
|
||||
' <head>',
|
||||
` <title>podsteadr catalog — ${xmlEscape(ctx.publicUrl)}</title>`,
|
||||
' </head>',
|
||||
' <body>',
|
||||
...outlines,
|
||||
' </body>',
|
||||
'</opml>',
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ export interface Settings {
|
||||
relays: string[];
|
||||
public_url: string;
|
||||
admin_pubkey: string | null;
|
||||
cashu_mint_url: string;
|
||||
}
|
||||
|
||||
export class SettingsService {
|
||||
@@ -30,6 +31,7 @@ export class SettingsService {
|
||||
relays: JSON.parse(this.get('relays') ?? 'null') ?? this.config.defaultRelays,
|
||||
public_url: this.get('public_url') ?? this.config.PUBLIC_URL,
|
||||
admin_pubkey: this.get('admin_pubkey'),
|
||||
cashu_mint_url: this.get('cashu_mint_url') ?? this.config.CASHU_MINT_URL_DEFAULT,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ export interface Podcast {
|
||||
keysend_node: string | null;
|
||||
value_suggested: string | null;
|
||||
podcast_guid: string;
|
||||
resale_producer_share_pct: number;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
@@ -37,10 +38,55 @@ export interface Episode {
|
||||
season: number | null;
|
||||
episode_no: number | null;
|
||||
source: 'upload' | 'recording';
|
||||
price_sats: number | null;
|
||||
pub_date: number;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
export interface Purchase {
|
||||
id: string;
|
||||
episode_id: string;
|
||||
buyer_pubkey: string;
|
||||
seller_pubkey: string;
|
||||
generation: number;
|
||||
amount_sats: number;
|
||||
receipt_event_id: string;
|
||||
receipt_json: string;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
export interface Reseller {
|
||||
episode_id: string;
|
||||
pubkey: string;
|
||||
download_url: string;
|
||||
price_sats: number;
|
||||
revoked_at: number | null;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
export interface Earning {
|
||||
id: string;
|
||||
purchase_id: string;
|
||||
pubkey: string;
|
||||
amount_sats: number;
|
||||
withdrawn_at: number | null;
|
||||
}
|
||||
|
||||
export interface CashuQuote {
|
||||
quote_id: string;
|
||||
purchase_ctx: string;
|
||||
state: 'pending' | 'paid' | 'settled' | 'expired';
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
export interface CashuProofRow {
|
||||
id: string;
|
||||
proofs_json: string;
|
||||
amount_sats: number;
|
||||
spent_at: number | null;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
export interface Stream {
|
||||
id: string;
|
||||
owner_pubkey: string;
|
||||
|
||||
Reference in New Issue
Block a user