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