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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user