Files
podsteadr/scripts/e2e.mjs
T
ssmithxandClaude Sonnet 5 e92ef7fef9 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>
2026-07-29 12:43:46 +00:00

225 lines
8.7 KiB
JavaScript

#!/usr/bin/env node
// End-to-end check against a running `docker compose up` stack.
// Exercises: NIP-98 login, settings, podcast creation, BUD-02 blossom upload,
// episode registration, RSS feed, stream keys + MediaMTX auth. RTMP publishing
// is driven separately (see scripts/e2e.sh) since it needs ffmpeg.
//
// Usage: node scripts/e2e.mjs (uses nostr-tools from server/node_modules)
import { createHash } from 'node:crypto';
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { finalizeEvent, generateSecretKey } from '../server/node_modules/nostr-tools/lib/esm/pure.js';
const BASE = process.env.PODSTEADR_URL ?? 'http://localhost:8095';
const BLOSSOM = process.env.BLOSSOM_URL ?? 'http://localhost:8098';
// Persist the test identity across runs — the first pubkey to log in becomes
// admin, so reruns must reuse it for the settings test to pass.
const KEY_FILE = '/tmp/podsteadr-e2e-key';
let sk;
if (existsSync(KEY_FILE)) {
sk = Uint8Array.from(Buffer.from(readFileSync(KEY_FILE, 'utf8').trim(), 'hex'));
} else {
sk = generateSecretKey();
writeFileSync(KEY_FILE, Buffer.from(sk).toString('hex'), { mode: 0o600 });
}
let cookie = '';
let failures = 0;
function ok(name, cond, extra = '') {
const pass = !!cond;
console.log(`${pass ? 'ok' : 'NOT OK'} - ${name}${extra ? ` (${extra})` : ''}`);
if (!pass) failures++;
return pass;
}
function nip98(url, method) {
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 api(method, path, body, headers = {}) {
const res = await fetch(`${BASE}${path}`, {
method,
headers: {
...(body ? { 'content-type': 'application/json' } : {}),
...(cookie ? { cookie } : {}),
...headers,
},
body: body ? JSON.stringify(body) : undefined,
});
const setCookie = res.headers.get('set-cookie');
if (setCookie) cookie = setCookie.split(';')[0];
let json = null;
try { json = await res.json(); } catch { /* non-JSON */ }
return { status: res.status, json };
}
// ---- login ----
const login = await api('POST', '/api/auth/login', undefined, {
authorization: nip98(`${BASE}/api/auth/login`, 'POST'),
});
ok('NIP-98 login', login.status === 200 && login.json.pubkey, `pubkey ${login.json?.pubkey?.slice(0, 8)}…`);
const badLogin = await api('POST', '/api/auth/login', undefined, {
authorization: nip98(`http://evil.example/api/auth/login`, 'POST'),
});
ok('login with wrong signed URL rejected', badLogin.status === 401);
// ---- settings: silence relays so tests never publish to public nostr relays ----
const settings = await api('PUT', '/api/settings', { relays: [] });
ok('admin can clear relay list', settings.status === 200 && settings.json.relays.length === 0);
// ---- podcast ----
const podcast = await api('POST', '/api/podcasts', {
title: 'E2E Test Show',
description: 'Automated test podcast',
author: 'e2e',
lightning_address: 'tester@getalby.com',
});
ok('podcast created', podcast.status === 201 && podcast.json.id);
const podcastId = podcast.json.id;
// ---- blossom upload (BUD-02, signed like the browser would) ----
const media = readFileSync(process.env.TEST_MP4 ?? '/tmp/podsteadr-test.mp4');
const sha256 = createHash('sha256').update(media).digest('hex');
const now = Math.floor(Date.now() / 1000);
const auth24242 = finalizeEvent(
{
kind: 24242,
created_at: now,
content: 'Upload e2e test',
tags: [['t', 'upload'], ['x', sha256], ['expiration', String(now + 600)]],
},
sk,
);
const up = await fetch(`${BLOSSOM}/upload`, {
method: 'PUT',
headers: {
authorization: `Nostr ${Buffer.from(JSON.stringify(auth24242)).toString('base64')}`,
'content-type': 'video/mp4',
},
body: media,
});
ok('blossom BUD-02 upload', up.ok, `status ${up.status}`);
// NOTE: blossom-server 4.x does not support range requests; check plain serving.
const head = await fetch(`${BLOSSOM}/${sha256}.mp4`, { method: 'HEAD' });
ok('blossom serves blob by hash.ext', head.ok);
// ---- episode registration + feed ----
const episode = await api('POST', `/api/podcasts/${podcastId}/episodes`, {
title: 'E2E Episode',
description: 'From the e2e script',
sha256,
size: media.length,
mime: 'video/mp4',
duration_secs: 5,
});
ok('episode registered', episode.status === 201, episode.json?.error);
const feed = await fetch(`${BASE}/feeds/${podcastId}/feed.xml`);
const xml = await feed.text();
ok('feed served as rss+xml', feed.ok && feed.headers.get('content-type').includes('application/rss+xml'));
ok('feed has lnaddress value block', xml.includes('method="lnaddress"') && xml.includes('tester@getalby.com'));
ok('feed has enclosure', xml.includes(`${sha256}.mp4`));
ok('feed has podcast:guid', /<podcast:guid>[0-9a-f-]{36}<\/podcast:guid>/.test(xml));
// ---- stream + mediamtx auth ----
const stream = await api('POST', '/api/streams', { title: 'E2E Live', hashtags: ['test'] });
ok('stream created', stream.status === 201 && stream.json.streamKey);
const { id: streamId, whipBearer } = stream.json;
const authOk = await api('POST', '/api/mediamtx/auth', {
action: 'publish', path: `live/${streamId}`, query: `key=${whipBearer}`, protocol: 'rtmp',
});
ok('mediamtx auth accepts right key', authOk.status === 200);
const authBad = await api('POST', '/api/mediamtx/auth', {
action: 'publish', path: `live/${streamId}`, query: 'key=wrong', protocol: 'rtmp',
});
ok('mediamtx auth rejects wrong key', authBad.status === 401);
// ---- marketplace: paywalled episode + Cashu purchase (producer side + invoice request only —
// actually paying the invoice and confirming needs a real sat payment, which this script can't
// do headlessly; that step is a manual check, see docs/STATUS.md). Uses a fresh throwaway
// buyer identity so it never collides with the persisted admin/producer one above. ----
const pricedEpisode = await api('POST', `/api/podcasts/${podcastId}/episodes`, {
title: 'E2E Priced Episode',
description: 'Paywalled',
sha256,
size: media.length,
mime: 'video/mp4',
price_sats: 21,
});
ok('priced episode registered', pricedEpisode.status === 201 && pricedEpisode.json.price_sats === 21);
const pricedEpisodeId = pricedEpisode.json.id;
const buyerSk = generateSecretKey();
let buyerCookie = '';
async function apiAsBuyer(method, path, body, headers = {}) {
const res = await fetch(`${BASE}${path}`, {
method,
headers: {
...(body ? { 'content-type': 'application/json' } : {}),
...(buyerCookie ? { cookie: buyerCookie } : {}),
...headers,
},
body: body ? JSON.stringify(body) : undefined,
});
const setCookie = res.headers.get('set-cookie');
if (setCookie) buyerCookie = setCookie.split(';')[0];
let json = null;
try { json = await res.json(); } catch { /* non-JSON */ }
return { status: res.status, json };
}
function nip98As(signingKey, url, method) {
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)]],
},
signingKey,
);
return `Nostr ${Buffer.from(JSON.stringify(event)).toString('base64')}`;
}
const buyerLogin = await apiAsBuyer('POST', '/api/auth/login', undefined, {
authorization: nip98As(buyerSk, `${BASE}/api/auth/login`, 'POST'),
});
ok('buyer identity logs in', buyerLogin.status === 200 && buyerLogin.json.isAdmin === false);
const gateBefore = await apiAsBuyer('GET', `/api/podcasts/${podcastId}/episodes/${pricedEpisodeId}/download-url`);
ok('download-url is gated before purchase', gateBefore.status === 402, `status ${gateBefore.status}`);
const sources = await fetch(`${BASE}/api/podcasts/${podcastId}/episodes/${pricedEpisodeId}/sources`);
const sourcesJson = await sources.json();
ok('sources lists the producer at the right price', sourcesJson.producer?.price_sats === 21);
const purchase = await apiAsBuyer('POST', `/api/podcasts/${podcastId}/episodes/${pricedEpisodeId}/purchase`, {
source: 'producer',
});
ok(
'purchase requests a real invoice from the configured Cashu mint',
purchase.status === 201 && purchase.json?.invoice?.startsWith('lnbc'),
purchase.json?.error ?? `invoice ${purchase.json?.invoice?.slice(0, 20)}…`,
);
console.log(
` -> to finish this check by hand: pay ${purchase.json?.invoice}, then ` +
`POST /api/podcasts/${podcastId}/episodes/${pricedEpisodeId}/purchase/${purchase.json?.quoteId}/confirm ` +
`as the buyer and re-check download-url.`,
);
console.log(JSON.stringify({ streamId, streamKey: stream.json.streamKey, podcastId }));
process.exit(failures === 0 ? 0 : 1);