diff --git a/README.md b/README.md index 44833f4..7657346 100644 --- a/README.md +++ b/README.md @@ -56,4 +56,17 @@ Tests: `cd server && npm test`. Everything lives in named docker volumes: `podpuddle-data` (SQLite, server nostr key, covers), `mediamtx-recordings` (stream recordings, 7-day retention), `blossom-data` -(media blobs). +(media blobs). `docker compose down` keeps them; `down -v` wipes them. + +## Verification + +- Unit tests: `cd server && npm test` (35 tests). +- Against a running stack: `node scripts/e2e.mjs` (14 checks: login, blossom + upload, feed, stream keys, MediaMTX auth webhook). + +## Status & internals + +See **[docs/STATUS.md](docs/STATUS.md)** for the architecture, the full +verification record, known gotchas (blossom v4 config nesting, no range +requests, split-horizon blossom URL, MediaMTX polling rationale), and the +remaining-work list (Archipelago packaging, git remote). diff --git a/docs/STATUS.md b/docs/STATUS.md new file mode 100644 index 0000000..01cab68 --- /dev/null +++ b/docs/STATUS.md @@ -0,0 +1,173 @@ +# podpuddle — project status & handoff + +_Last updated: 2026-07-10. State: **working, verified end-to-end, parked.**_ + +## What this is + +A self-hosted, docker-based server + web frontend for nostr-native podcasting and +livestreaming. A user opens the web page, logs in **with nostr only** (NIP-07 +extension), answers wizard questions, and either uploads an mp4 (published to an +RSS 2.0 feed with lightning payment info, media stored on Blossom) or goes live +(OBS/RTMP or browser/WHIP through MediaMTX, announced on nostr as a NIP-53 live +event, watchable via HLS). Live streams are recorded and can be published as +podcast episodes with one click. + +Intended to eventually run as an Archipelago app (see "Remaining work"), but is a +fully standalone compose stack today. + +## How to resume + +```sh +cd ~/podpuddle +docker compose up -d # data volumes were kept; everything resumes as-is +node scripts/e2e.mjs # 14 automated checks against the running stack +``` + +Open http://localhost:8095 and log in with a NIP-07 extension (Alby, nos2x). +For a non-localhost box, edit `.env` (`PUBLIC_HOST=…`) and `docker compose up -d --build`. + +State that persists in the named volumes: `podpuddle-data` (SQLite DB, the +server's nostr key in `config/server-nostr-key`, admin = first pubkey that ever +logged in), `blossom-data` (media blobs), `mediamtx-recordings` (stream +recordings, 7-day retention). `docker compose down -v` wipes all of it. + +Note: the automated e2e run left test data behind (an "E2E Test Show" podcast +with 2 episodes, test streams, and the e2e identity as **admin**). The e2e +identity's secret key is in `/tmp/podpuddle-e2e-key` (hex). For real use you +probably want a clean slate: `docker compose down -v && docker compose up -d`, +then log in with your own extension first so *your* pubkey becomes admin. + +## Architecture + +Three containers on one compose network: + +| Container | Image | Host ports | Role | +|---|---|---|---| +| `podpuddle` | built from `Dockerfile` (node:22 + ffmpeg) | 8095 | Fastify API + built Vue UI + RSS feeds | +| `podpuddle-mediamtx` | `bluenviron/mediamtx:1.19.2` | 1935 (RTMP), 8889 (WHIP), 8189/udp (ICE), 8890→8888 (HLS) | ingest + HLS output + recording | +| `podpuddle-blossom` | `ghcr.io/hzrd149/blossom-server:4` (4.4.1) | 8098→3000 | sha256-addressed media blobs | + +Key flows: + +- **Login** — browser signs a kind-27235 (NIP-98) event via NIP-07 → + `POST /api/auth/login` → server verifies (sig, `u`/`method` tags, ±60 s skew, + replay-guard table) → httpOnly session cookie. NIP-98 headers also accepted + directly on any API route (scripting). First pubkey to log in becomes admin. +- **Episode upload** — browser hashes the file (`crypto.subtle`, ≤2 GB), signs a + kind-24242 BUD-02 auth via NIP-07, PUTs **directly to blossom** (file never + transits podpuddle), then registers `{sha256, size, …}` with + `POST /api/podcasts/:id/episodes`; server HEAD-verifies the blob before + accepting. Enclosure URL = `/.mp4`. +- **RSS** — `GET /feeds/:podcastId/feed.xml`, RSS 2.0 + itunes + podcast + namespace: `podcast:guid` (UUIDv5 per spec, verified against the spec test + vector), `` with the + owner's lightning address (falls back to a keysend/node block if only a node + pubkey is set; omitted if neither). +- **Streaming** — `POST /api/streams` returns `{streamId, streamKey (once), + rtmpUrl, whipUrl, hlsUrl}`. Path is `live/` (public); the secret + travels as `?key=` (OBS stream key) or WHIP `Bearer`, is stored only as a + sha256 hash, and never appears in viewer URLs. MediaMTX delegates auth to + `POST /api/mediamtx/auth` (2xx = allow); reads are public. +- **Status / NIP-53** — podpuddle polls the MediaMTX API (`:9997/v3/paths/list`, + every 3 s) and flips `planned → live → ended` (the stock MediaMTX image has no + shell, so `runOn*` curl hooks are impossible — polling is deliberate). On each + transition it (re)publishes the kind-30311 live event **signed by the server's + own nostr key** (generated at first boot, `data/config/server-nostr-key`), + with the user `p`-tagged as `host`, to the relay list in Settings. 30311 is + replaceable, which is why the server key signs rather than the user's. +- **Recordings** — MediaMTX records fmp4 segments to the shared volume; + podpuddle mounts it read-only, lists per-stream files, and + `POST /api/streams/:id/recordings/publish` remuxes (`ffmpeg -c copy + +faststart`), uploads to blossom **with the server's key** (the only + server-signed upload path), and creates the episode. +- **External blossom** — Settings → Blossom URL is a pure switch because browser + uploads are user-signed. Caveat: recording publishing is server-signed, so an + external blossom must accept uploads from the server's pubkey (shown in + `GET /api/settings/public` as `serverPubkey`). + +## Repo layout + +``` +docker-compose.yml # the run path; .env / .env.example for host config +Dockerfile # multi-stage: frontend build → server build → node+ffmpeg runtime +mediamtx/mediamtx.yml # ingest/auth/HLS/recording config (mounted read-only) +blossom/config.yml # blossom-server v4 config (mounted read-only) +scripts/e2e.mjs # automated checks against a running stack +server/ # Fastify + TypeScript + better-sqlite3 (WAL) + src/app.ts # app assembly + DI context (config/db/settings/publisher/mediamtx/serverKey) + src/index.ts # bootstrap + MediaMTX status poller wiring + src/plugins/nostr-auth.ts # NIP-98 verify + session decorators + src/services/ # nip98, rss, blossom, mediamtx, nostr (30311), streamkeys, ffmpeg, settings + src/routes/ # auth, settings, podcasts (episodes), feeds, streams (recordings), mediamtx (webhook) + src/db/migrations.ts # schema (users, sessions, settings, podcasts, episodes, streams, recordings, auth_events) +frontend/ # Vue 3 + Vite + Tailwind + Pinia + src/lib/ # nip07 (ext bridge), blossom (BUD-02 upload), whip (WHIP client), api + src/views/ # Login, Home, wizard/{EpisodeWizard,LiveWizard}, StreamDashboard, Settings +``` + +## Verification record (2026-07-10) + +- `cd server && npm test` — **35/35** vitest (NIP-98 skew/replay/URL/method/payload + tampering, RSS spec vectors incl. `podcast:guid`, app integration: login, + admin claim, episode blob verification, feed ETag/304, stream key + create/verify/rotate, webhook allow/deny paths, settings admin gating). +- `node scripts/e2e.mjs` against the compose stack — **14/14**. +- Live leg (manual, via ffmpeg in the podpuddle container): + - RTMP publish with the right key → HLS `index.m3u8` 200 (after MediaMTX's + `?cookieCheck=1` redirect — use `curl -L`). + - Wrong key → connection reset by MediaMTX (webhook denial confirmed live). + - Poller set `starts_at`/`ended_at` correctly (planned→live→ended). + - Recording remuxed, uploaded to blossom, published as episode: feed grew to + 2 items, XML well-formed, `duration_secs` probed (35 s), enclosure on the + public blossom URL. +- NIP-53 relay publishing was **not** exercised against public relays (the e2e + intentionally blanks the relay list, restored afterwards). To verify: create a + stream in the UI, go live, then `nak req -k 30311` on one of the configured + relays or open the stream in zap.stream. + +## Gotchas (hard-won, do not rediscover) + +1. **blossom-server 4.4.1 config**: `rules:` must be nested under `storage:` — + top-level `rules:` is silently ignored, the ruleset is empty, and every + upload fails 401 `"Server dose not accept video/mp4 blobs"` (typo theirs). + The GitHub master branch is a Deno rewrite with a *different* schema + (`storage.rules`, BUD-11, range support) — the `:4` image is the older + node/koa codebase. Rule `expiration` is time-since-last-access (prune), set + to `10 years` for podcast media. +2. **No HTTP range support** in blossom-server 4.x (`Range:` requests get 200 + + full body). Most podcast apps cope (they download), but Apple-Podcasts-style + streaming/seeking is degraded. Options later: upgrade to the Deno rewrite, + or front blossom with nginx. +3. **Split-horizon blossom URL**: browsers use `http://:8098`, but inside + the podpuddle container that address doesn't exist. `BLOSSOM_URL_INTERNAL` + (compose sets `http://blossom:3000`) is used server-side whenever the active + blossom URL equals the bundled default; feeds always carry the public URL. +4. **Stock MediaMTX image has no shell** — `runOnReady`-style hooks can't run + curl. Status is polled from the API instead. If you ever switch to the + `-ffmpeg` image variant, hooks become possible but polling still works. +5. **MediaMTX 1.19 config**: `hlsAllowOrigins`/`webrtcAllowOrigins` (plural, + list) — the singular forms are deprecated. MoQ is disabled (`moq: no`). +6. **NIP-98 event ids collide** if the same key signs identical requests within + one second — the replay guard then rejects the second. Clients should add a + nonce tag (the frontend's real logins are naturally unique; tests add one). + +## Remaining work + +- **Archipelago packaging** (the original deployment target, archy repo): + `apps/podpuddle/manifest.yml` + `apps/podpuddle-mediamtx` + `apps/podpuddle-blossom` + following the `apps/btcpay-server` (dependencies) + `apps/monero-ui` + (`container.build` on `/opt/archipelago/docker/...`) patterns; bind volumes + under `/var/lib/archipelago/`; add ports **8095, 1935, 8889, 8189/udp, + 8890, 8098** to `apps/PORTS.md` (chosen 2026-07-10 to avoid fleet collisions — + 8888 is searxng, hence HLS on 8890). `interfaces.main` → port 8095. +- **No git remote yet** — decide where to push (gitea?). +- Browser-tested only synthetically: the wizards should get a real pass with an + actual NIP-07 extension + OBS (the API surface they call is fully covered by + the e2e script, so surprises should be cosmetic). +- Nice-to-haves parked: user-signed 30311 mode (needs an event-template + endpoint — **not implemented**; the builder to reuse is + `buildLiveEvent()` in `server/src/services/nostr.ts`), zap/chat integration + (NIP-53 `relays` tag), feed covers + uploaded to blossom instead of URL-only, chunked hashing for >2 GB files + (hash-wasm), range-request support (see gotcha 2), SSE instead of 4 s polling + on the stream dashboard.