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