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