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

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

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

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

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

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