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
+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);