docs: add STATUS.md handoff (architecture, verification record, gotchas, remaining work)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-10 19:39:22 +00:00
co-authored by Claude Fable 5
parent 6dd541b1a4
commit 26d166a7e8
2 changed files with 187 additions and 1 deletions
+14 -1
View File
@@ -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).
+173
View File
@@ -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 = `<blossom>/<sha256>.mp4`.
- **RSS** — `GET /feeds/:podcastId/feed.xml`, RSS 2.0 + itunes + podcast
namespace: `podcast:guid` (UUIDv5 per spec, verified against the spec test
vector), `<podcast:value type="lightning" method="lnaddress">` 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/<streamId>` (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://<host>: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/<app>`; 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.