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