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