feat(server): podpuddle scaffold + Fastify backend (nostr auth, RSS, streams)

- docker-compose stack: podpuddle + MediaMTX (RTMP/WHIP/HLS) + blossom-server
- NIP-98 nostr-only login with session cookies, replay guard, clock-skew window
- podcasts/episodes CRUD; episodes register browser-uploaded blossom blobs
- RSS 2.0 + itunes + podcast namespace feeds with lnaddress value blocks
  (podcast:guid UUIDv5 verified against the spec vector)
- streams API with hashed stream keys; MediaMTX http-auth webhook
  (query/password/bearer forms); API poller flips live/ended status
- NIP-53 kind 30311 live events published with the server's nostr identity
- recordings: ffmpeg remux + server-key blossom upload → podcast episode
- 35 vitest tests green

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-10 19:01:38 +00:00
co-authored by Claude Fable 5
commit 594a9a8783
35 changed files with 5655 additions and 0 deletions
+298
View File
@@ -0,0 +1,298 @@
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 } from 'nostr-tools/pure';
import type { FastifyInstance } from 'fastify';
import { buildApp } from './app.js';
import { loadConfig } from './config.js';
const sk = generateSecretKey();
const pk = getPublicKey(sk);
let app: FastifyInstance;
let dataDir: string;
let cookie: string;
function nip98Header(url: string, method: string): string {
const event = finalizeEvent(
{
kind: 27235,
created_at: Math.floor(Date.now() / 1000),
content: '',
// nonce keeps event ids unique when tests sign several events in one second
tags: [['u', url], ['method', method], ['nonce', Math.random().toString(36).slice(2)]],
},
sk,
);
return `Nostr ${Buffer.from(JSON.stringify(event)).toString('base64')}`;
}
beforeAll(async () => {
dataDir = mkdtempSync(join(tmpdir(), 'podpuddle-test-'));
const config = loadConfig({
DATA_DIR: dataDir,
PUBLIC_URL: 'http://localhost:8095',
NOSTR_RELAYS: '', // no relay publishing in tests
} as NodeJS.ProcessEnv);
app = await buildApp({ config, dbPath: ':memory:', logger: false });
});
afterAll(async () => {
await app.close();
rmSync(dataDir, { recursive: true, force: true });
});
describe('auth', () => {
it('rejects unauthenticated /api/auth/me', async () => {
const res = await app.inject({ method: 'GET', url: '/api/auth/me' });
expect(res.statusCode).toBe(401);
});
it('logs in with a NIP-98 header and sets a session cookie', async () => {
const res = await app.inject({
method: 'POST',
url: '/api/auth/login',
headers: { authorization: nip98Header('http://localhost:8095/api/auth/login', 'POST') },
});
expect(res.statusCode).toBe(200);
expect(res.json().pubkey).toBe(pk);
expect(res.json().isAdmin).toBe(true); // first login claims admin
const setCookie = res.headers['set-cookie'] as string;
expect(setCookie).toContain('podpuddle_session=');
cookie = setCookie.split(';')[0];
});
it('rejects a replayed login header', async () => {
const header = nip98Header('http://localhost:8095/api/auth/login', 'POST');
const first = await app.inject({ method: 'POST', url: '/api/auth/login', headers: { authorization: header } });
expect(first.statusCode).toBe(200);
const second = await app.inject({ method: 'POST', url: '/api/auth/login', headers: { authorization: header } });
expect(second.statusCode).toBe(401);
expect(second.json().error).toMatch(/already used/);
});
it('serves /api/auth/me with the session cookie', async () => {
const res = await app.inject({ method: 'GET', url: '/api/auth/me', headers: { cookie } });
expect(res.statusCode).toBe(200);
expect(res.json().pubkey).toBe(pk);
});
});
describe('podcasts, episodes, feed', () => {
let podcastId: string;
const sha = 'c'.repeat(64);
it('creates a podcast', async () => {
const res = await app.inject({
method: 'POST',
url: '/api/podcasts',
headers: { cookie },
payload: {
title: 'My Show',
description: 'About things',
author: 'Tester',
lightning_address: 'tester@getalby.com',
},
});
expect(res.statusCode).toBe(201);
podcastId = res.json().id;
expect(res.json().podcast_guid).toMatch(/^[0-9a-f-]{36}$/);
});
it('registers an episode after verifying the blob on blossom', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () =>
new Response(null, { status: 200, headers: { 'content-length': '1000' } }),
),
);
try {
const res = await app.inject({
method: 'POST',
url: `/api/podcasts/${podcastId}/episodes`,
headers: { cookie },
payload: { title: 'Ep 1', sha256: sha, size: 1000, mime: 'video/mp4' },
});
expect(res.statusCode).toBe(201);
expect(res.json().enclosure_url).toContain(`${sha}.mp4`);
} finally {
vi.unstubAllGlobals();
}
});
it('rejects an episode whose blob size mismatches', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () =>
new Response(null, { status: 200, headers: { 'content-length': '999' } }),
),
);
try {
const res = await app.inject({
method: 'POST',
url: `/api/podcasts/${podcastId}/episodes`,
headers: { cookie },
payload: { title: 'Bad', sha256: 'd'.repeat(64), size: 1000 },
});
expect(res.statusCode).toBe(422);
} finally {
vi.unstubAllGlobals();
}
});
it('serves a valid feed with the lnaddress value block', async () => {
const res = await app.inject({ method: 'GET', url: `/feeds/${podcastId}/feed.xml` });
expect(res.statusCode).toBe(200);
expect(res.headers['content-type']).toContain('application/rss+xml');
expect(res.body).toContain('method="lnaddress"');
expect(res.body).toContain('tester@getalby.com');
expect(res.body).toContain(`${sha}.mp4`);
const etag = res.headers.etag as string;
const cached = await app.inject({
method: 'GET',
url: `/feeds/${podcastId}/feed.xml`,
headers: { 'if-none-match': etag },
});
expect(cached.statusCode).toBe(304);
});
});
describe('streams + mediamtx auth webhook', () => {
let streamId: string;
let streamKey: string;
it('creates a stream and returns the key once', async () => {
const res = await app.inject({
method: 'POST',
url: '/api/streams',
headers: { cookie },
payload: { title: 'Live Test', summary: 'hi', hashtags: ['podpuddle'] },
});
expect(res.statusCode).toBe(201);
const body = res.json();
streamId = body.id;
streamKey = body.whipBearer;
expect(body.streamKey).toBe(`${streamId}?key=${streamKey}`);
expect(body.hlsUrl).toContain(`/live/${streamId}/index.m3u8`);
expect(body.stream_key_hash).toBeUndefined(); // never leak the hash
});
it('allows publish with the right key (query form, as OBS sends it)', async () => {
const res = await app.inject({
method: 'POST',
url: '/api/mediamtx/auth',
payload: { action: 'publish', path: `live/${streamId}`, query: `key=${streamKey}`, protocol: 'rtmp' },
});
expect(res.statusCode).toBe(200);
});
it('allows publish with the key as WHIP bearer password', async () => {
const res = await app.inject({
method: 'POST',
url: '/api/mediamtx/auth',
payload: { action: 'publish', path: `live/${streamId}`, password: streamKey, protocol: 'webrtc' },
});
expect(res.statusCode).toBe(200);
});
it('denies publish with a wrong key', async () => {
const res = await app.inject({
method: 'POST',
url: '/api/mediamtx/auth',
payload: { action: 'publish', path: `live/${streamId}`, query: 'key=wrong', protocol: 'rtmp' },
});
expect(res.statusCode).toBe(401);
});
it('denies publish to an unknown path', async () => {
const res = await app.inject({
method: 'POST',
url: '/api/mediamtx/auth',
payload: { action: 'publish', path: 'live/nosuchstream', query: 'key=x' },
});
expect(res.statusCode).toBe(401);
});
it('allows reads without a key', async () => {
const res = await app.inject({
method: 'POST',
url: '/api/mediamtx/auth',
payload: { action: 'read', path: `live/${streamId}` },
});
expect(res.statusCode).toBe(200);
});
it('rotating the key invalidates the old one', async () => {
const rot = await app.inject({
method: 'POST',
url: `/api/streams/${streamId}/rotate-key`,
headers: { cookie },
});
expect(rot.statusCode).toBe(200);
const oldKey = await app.inject({
method: 'POST',
url: '/api/mediamtx/auth',
payload: { action: 'publish', path: `live/${streamId}`, query: `key=${streamKey}` },
});
expect(oldKey.statusCode).toBe(401);
const newKey = await app.inject({
method: 'POST',
url: '/api/mediamtx/auth',
payload: { action: 'publish', path: `live/${streamId}`, query: `key=${rot.json().whipBearer}` },
});
expect(newKey.statusCode).toBe(200);
});
});
describe('settings', () => {
it('exposes public settings without auth', async () => {
const res = await app.inject({ method: 'GET', url: '/api/settings/public' });
expect(res.statusCode).toBe(200);
expect(res.json().blossomUrl).toBeTruthy();
expect(res.json().serverPubkey).toMatch(/^[0-9a-f]{64}$/);
});
it('lets the admin switch to an external blossom server', async () => {
const res = await app.inject({
method: 'PUT',
url: '/api/settings',
headers: { cookie },
payload: { blossom_url: 'https://blossom.example.com' },
});
expect(res.statusCode).toBe(200);
expect(res.json().blossom_url).toBe('https://blossom.example.com');
const pub = await app.inject({ method: 'GET', url: '/api/settings/public' });
expect(pub.json().blossomUrl).toBe('https://blossom.example.com');
});
it('blocks settings changes from non-admin users', async () => {
const sk2 = generateSecretKey();
const event = finalizeEvent(
{
kind: 27235,
created_at: Math.floor(Date.now() / 1000),
content: '',
tags: [['u', 'http://localhost:8095/api/auth/login'], ['method', 'POST']],
},
sk2,
);
const login = await app.inject({
method: 'POST',
url: '/api/auth/login',
headers: { authorization: `Nostr ${Buffer.from(JSON.stringify(event)).toString('base64')}` },
});
expect(login.statusCode).toBe(200);
expect(login.json().isAdmin).toBe(false);
const cookie2 = (login.headers['set-cookie'] as string).split(';')[0];
const res = await app.inject({
method: 'PUT',
url: '/api/settings',
headers: { cookie: cookie2 },
payload: { blossom_url: 'https://evil.example.com' },
});
expect(res.statusCode).toBe(403);
});
});