feat: recorded-episode pricing, Cashu-token payment, podcast editing

- Editable podcast settings: new /podcasts/:id/settings page, reusing
  PodcastForm.vue in an edit mode (PUT instead of POST) since it was
  previously create-only with no way to fix a field (e.g. lightning
  address) after the fact.
- Recorded episodes can now be priced same as uploads: "Publish
  recording" gained an optional price_sats field, wired through the
  existing episode paywall machinery. Live streams themselves stay
  unpaywalled by design — only the resulting recording can be priced.
- Accept Cashu tokens as an alternative to a Lightning invoice:
  POST .../purchase/token redeems a pasted token directly (via the
  mint's swap/receive flow) and finalizes the purchase in one step,
  no quote/confirm round trip. A token worth more than the price is
  treated as a tip (seller gets the full amount); worth less is
  rejected. Added a "pay with a Cashu token instead" option next to
  the existing invoice flow.
- cashu.ts: fixed payout() always requesting an invoice for the full
  held balance with no room for the mint's routing-fee reserve, which
  made a balance that exactly matched one sale's price permanently
  unwithdrawable (needed slightly more than held to cover the fee).
  Now shrinks the request and requotes once if the first quote doesn't
  fit.
- docker-compose.yml / mediamtx.yml: renamed the podsteadr container's
  DNS alias away from the literal string "podsteadr" — on a host whose
  own hostname is "podsteadr", cloud-init's self-hostname /etc/hosts
  entry shadowed the container-network alias, so mediamtx's auth
  webhook callback resolved to the wrong address and rejected every
  RTMP publish attempt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-30 23:18:36 +00:00
co-authored by Claude Sonnet 5
parent a050e2e5f9
commit e04d35b131
12 changed files with 401 additions and 38 deletions
+98 -2
View File
@@ -21,7 +21,14 @@ vi.mock('../services/cashu.js', () => {
return 'fake-proof-row-id';
}
async payout(lud16: string, amountSats: number) {
return { preimage: `fake-preimage-for-${lud16}-${amountSats}` };
return { preimage: `fake-preimage-for-${lud16}-${amountSats}`, paidSats: amountSats };
}
// Fake tokens are just "fake-token-<sats>" — real ones would be decoded and swapped with
// the mint; here the "amount" is however many sats the test wrote into the string.
async receiveToken(token: string) {
const match = /^fake-token-(\d+)$/.exec(token);
if (!match) throw new CashuTokenInvalidError(`could not redeem token: malformed test token "${token}"`);
return { amountSats: Number(match[1]), rowId: 'fake-proof-row-id' };
}
}
class CashuClientPool {
@@ -31,7 +38,8 @@ vi.mock('../services/cashu.js', () => {
}
class CashuUnreachableError extends Error {}
class CashuInsufficientFundsError extends Error {}
return { CashuClientPool, CashuUnreachableError, CashuInsufficientFundsError };
class CashuTokenInvalidError extends Error {}
return { CashuClientPool, CashuUnreachableError, CashuInsufficientFundsError, CashuTokenInvalidError };
});
const { buildApp } = await import('../app.js');
@@ -209,6 +217,94 @@ describe('purchase flow', () => {
});
});
describe('paying with a Cashu token', () => {
// Isolated podcast/episode/producer — purchases here would otherwise credit the shared
// producerPk's earnings and break the later "earnings" describe block's exact-total assertions.
let tokenProducerPk: string;
let tokenPodcastId: string;
let tokenEpisodeId: string;
beforeAll(async () => {
const sk = generateSecretKey();
tokenProducerPk = getPublicKey(sk);
const cookie = await login(sk);
const podcastRes = await app.inject({
method: 'POST',
url: '/api/podcasts',
headers: { cookie },
payload: { title: 'Token-Paid Show', lightning_address: 'tokenproducer@getalby.com' },
});
tokenPodcastId = 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/${tokenPodcastId}/episodes`,
headers: { cookie },
payload: { title: 'Token-Paid Ep', sha256: 'f'.repeat(64), size: 1000, mime: 'video/mp4', price_sats: 500 },
});
vi.unstubAllGlobals();
tokenEpisodeId = episodeRes.json().id;
});
it('redeems a token worth exactly the price and unlocks the episode', async () => {
const sk = generateSecretKey();
const pk = getPublicKey(sk);
const cookie = await login(sk);
const res = await app.inject({
method: 'POST',
url: `/api/podcasts/${tokenPodcastId}/episodes/${tokenEpisodeId}/purchase/token`,
headers: { cookie },
payload: { source: 'producer', token: 'fake-token-500' },
});
expect(res.statusCode).toBe(201);
const purchase = res.json();
expect(purchase.buyer_pubkey).toBe(pk);
expect(purchase.seller_pubkey).toBe(tokenProducerPk);
expect(purchase.amount_sats).toBe(500);
});
it('treats a token worth more than the price as a tip, crediting the full amount', async () => {
const sk = generateSecretKey();
const cookie = await login(sk);
const res = await app.inject({
method: 'POST',
url: `/api/podcasts/${tokenPodcastId}/episodes/${tokenEpisodeId}/purchase/token`,
headers: { cookie },
payload: { source: 'producer', token: 'fake-token-600' },
});
expect(res.statusCode).toBe(201);
expect(res.json().amount_sats).toBe(600);
});
it('rejects a token worth less than the price', async () => {
const sk = generateSecretKey();
const cookie = await login(sk);
const res = await app.inject({
method: 'POST',
url: `/api/podcasts/${tokenPodcastId}/episodes/${tokenEpisodeId}/purchase/token`,
headers: { cookie },
payload: { source: 'producer', token: 'fake-token-100' },
});
expect(res.statusCode).toBe(400);
expect(res.json().error).toMatch(/worth 100 sats/);
});
it('rejects a token it cannot redeem', async () => {
const sk = generateSecretKey();
const cookie = await login(sk);
const res = await app.inject({
method: 'POST',
url: `/api/podcasts/${tokenPodcastId}/episodes/${tokenEpisodeId}/purchase/token`,
headers: { cookie },
payload: { source: 'producer', token: 'not-a-real-token' },
});
expect(res.statusCode).toBe(400);
expect(res.json().error).toMatch(/could not redeem token/);
});
});
describe('resale', () => {
const resellerMirrorUrl = 'https://mirror.example.com';
let resellerQuoteId: string;
+112 -2
View File
@@ -2,7 +2,7 @@ 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 { CashuUnreachableError, CashuInsufficientFundsError, CashuTokenInvalidError } from '../services/cashu.js';
import { getEpisodeSources } from '../services/marketplace.js';
import type { Earning, Episode, Podcast, Purchase, Reseller, User } from '../types.js';
@@ -21,6 +21,11 @@ const purchaseSchema = z.object({
source: z.string().min(1),
});
const tokenPurchaseSchema = z.object({
source: z.string().min(1),
token: z.string().min(1),
});
const resellerSchema = z.object({
download_url: z.string().url(),
price_sats: z.number().int().positive(),
@@ -188,6 +193,111 @@ export default async function marketplaceRoutes(app: FastifyInstance) {
},
);
// Pay with a Cashu token directly instead of a Lightning invoice — no quote/confirm round
// trip needed, since the token itself already represents settled value. Redeems the token
// and finalizes the purchase (receipt, earnings split) in one step.
app.post(
'/api/podcasts/:id/episodes/:eid/purchase/token',
{ 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 = tokenPurchaseSchema.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 priceSats: number;
if (source === 'producer') {
priceSats = episode.price_sats;
} else {
const reseller = getReseller.get(eid, source) as Reseller | undefined;
if (!reseller) return reply.code(404).send({ error: 'reseller not found' });
priceSats = reseller.price_sats;
}
const cashu = app.ctx.cashu.client(settings.all().cashu_mint_url);
let received: { amountSats: number; rowId: string };
try {
received = await cashu.receiveToken(parsed.data.token);
} catch (err) {
if (err instanceof CashuTokenInvalidError) return reply.code(400).send({ error: err.message });
throw err;
}
if (received.amountSats < priceSats) {
return reply.code(400).send({
error: `token is worth ${received.amountSats} sats, episode costs ${priceSats}`,
});
}
// A token worth more than the price is treated as a tip — the seller gets the full
// redeemed amount, same as an over-generous Lightning payment would.
const amountSats = received.amountSats;
// Resolve seller(s) + split against current state, same as the invoice-confirm path.
let sellerPubkey: string;
let generation: number;
let parentPurchaseId: string | undefined;
let earningsRows: Array<{ pubkey: string; amountSats: number }>;
if (source === 'producer') {
sellerPubkey = podcast.owner_pubkey;
generation = 0;
earningsRows = [{ pubkey: sellerPubkey, amountSats }];
} else {
const reseller = getReseller.get(eid, source) as Reseller | undefined;
const parentPurchase = getPurchase.get(eid, source) as Purchase | undefined;
if (!reseller || !parentPurchase) {
return reply.code(409).send({ error: 'reseller is no longer certified for this episode' });
}
sellerPubkey = source;
generation = parentPurchase.generation + 1;
parentPurchaseId = parentPurchase.id;
const producerSats = Math.round((amountSats * podcast.resale_producer_share_pct) / 100);
earningsRows = [
{ pubkey: podcast.owner_pubkey, amountSats: producerSats },
{ pubkey: sellerPubkey, amountSats: amountSats - producerSats },
];
}
const purchaseId = randomUUID();
const receipt = await publisher.publishPurchaseReceipt(settings.all().relays, {
purchaseId,
episodeSha256: episode.sha256,
buyerPubkey,
sellerPubkey,
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, buyerPubkey, sellerPubkey, generation, 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);
});
tx();
return reply.code(201).send(getPurchase.get(eid, 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,
@@ -327,6 +437,6 @@ export default async function marketplaceRoutes(app: FastifyInstance) {
});
tx();
return { paid_sats: totalSats, preimage: result.preimage };
return { paid_sats: result.paidSats, preimage: result.preimage };
});
}
+4 -3
View File
@@ -24,6 +24,7 @@ const publishRecordingSchema = z.object({
podcast_id: z.string().uuid(),
title: z.string().min(1).max(300),
description: z.string().max(10000).default(''),
price_sats: z.number().int().positive().nullish(),
});
export default async function streamRoutes(app: FastifyInstance) {
@@ -174,10 +175,10 @@ export default async function streamRoutes(app: FastifyInstance) {
const eid = randomUUID();
db.prepare(`
INSERT INTO episodes (id, podcast_id, title, description, sha256, enclosure_url,
enclosure_length, enclosure_type, duration_secs, source, pub_date, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, 'video/mp4', ?, 'recording', ?, ?)
enclosure_length, enclosure_type, duration_secs, source, price_sats, pub_date, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, 'video/mp4', ?, 'recording', ?, ?, ?)
`).run(eid, d.podcast_id, d.title, d.description, uploaded.sha256,
`${publicBlossom}/${uploaded.sha256}.mp4`, uploaded.size, duration, nowSecs(), nowSecs());
`${publicBlossom}/${uploaded.sha256}.mp4`, uploaded.size, duration, d.price_sats ?? null, nowSecs(), nowSecs());
db.prepare('UPDATE podcasts SET updated_at = ? WHERE id = ?').run(nowSecs(), d.podcast_id);
return reply.code(201).send(
db.prepare('SELECT * FROM episodes WHERE id = ?').get(eid) as Episode,
+47 -8
View File
@@ -9,6 +9,7 @@ function nowSecs(): number {
export class CashuUnreachableError extends Error {}
export class CashuInsufficientFundsError extends Error {}
export class CashuTokenInvalidError extends Error {}
export interface MintQuote {
quoteId: string;
@@ -65,6 +66,26 @@ export class CashuMintClient {
return this.storeProofs(proofs, amountSats);
}
/**
* Redeems an incoming Cashu token as payment — no invoice, no Lightning round-trip. The
* token's proofs are swapped for the server's own (invalidating the sender's copy) and
* custodied exactly like a Lightning-funded purchase. Only tokens from this same mint are
* accepted — the mint's own swap call rejects proofs it didn't issue, so a wrong-mint token
* fails here with whatever error the mint gives rather than a bespoke pre-check.
*/
async receiveToken(token: string): Promise<{ amountSats: number; rowId: string }> {
await this.ensureLoaded();
let proofs: Proof[];
try {
proofs = await this.wallet.ops.receive(token).run();
} catch (err) {
throw new CashuTokenInvalidError(`could not redeem token: ${(err as Error).message}`);
}
const amountSats = proofs.reduce((sum, p) => sum + p.amount.toNumber(), 0);
const rowId = this.storeProofs(proofs, amountSats);
return { amountSats, rowId };
}
private storeProofs(proofs: Proof[], amountSats: number): string {
const id = randomUUID();
this.db
@@ -83,14 +104,32 @@ export class CashuMintClient {
/**
* 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.
* (LUD-16 / LUD-06), melts enough held proofs to pay it, and returns the payment preimage
* plus however many sats actually went out (see below).
*
* The mint charges a small routing-fee reserve on top of the invoice amount, paid out of the
* held balance — so requesting an invoice for the full nominal balance always needs slightly
* *more* than that balance to melt. Left uncorrected, a balance that exactly matches what's
* owed (the common case: proceeds of one sale, no cushion) can never be withdrawn at all. If
* the first quote doesn't fit what's held, shrink the invoice request by the shortfall and
* requote once — the payout is a few sats less than nominal, same as any other Lightning
* withdrawal fee, rather than failing outright.
*/
async payout(lud16: string, amountSats: number): Promise<{ preimage: string | null }> {
async payout(lud16: string, amountSats: number): Promise<{ preimage: string | null; paidSats: number }> {
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 heldSats = this.totalHeldSats();
let requestSats = amountSats;
let invoice = await this.resolveLnurlpInvoice(lud16, requestSats);
let meltQuote = await this.wallet.createMeltQuoteBolt11(invoice);
let needed = meltQuote.amount.add(meltQuote.fee_reserve).toNumber();
if (needed > heldSats) {
requestSats = Math.max(1, amountSats - (needed - heldSats));
invoice = await this.resolveLnurlpInvoice(lud16, requestSats);
meltQuote = await this.wallet.createMeltQuoteBolt11(invoice);
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')
@@ -104,7 +143,7 @@ export class CashuMintClient {
}
if (sum < needed) {
throw new CashuInsufficientFundsError(
`held ${sum} sats, need ${needed} sats to pay out ${amountSats} to ${lud16}`,
`held ${sum} sats, need ${needed} sats to pay out ${requestSats} to ${lud16}`,
);
}
@@ -121,7 +160,7 @@ export class CashuMintClient {
});
spendTx();
return { preimage: result.quote.payment_preimage };
return { preimage: result.quote.payment_preimage, paidSats: requestSats };
}
private async resolveLnurlpInvoice(lud16: string, amountSats: number): Promise<string> {