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
+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"`);
});
});