# podsteadr — 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 ~/podsteadr 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: `podsteadr-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/podsteadr-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 | |---|---|---|---| | `podsteadr` | built from `Dockerfile` (node:22 + ffmpeg) | 8095 | Fastify API + built Vue UI + RSS feeds | | `podsteadr-mediamtx` | `bluenviron/mediamtx:1.20.0` | 1935 (RTMP), 8889 (WHIP), 8189/udp (ICE), 8890→8888 (HLS) | ingest + HLS output + recording | | `podsteadr-blossom` | `ghcr.io/hzrd149/blossom-server:6.4.1` | 8098→3000 | sha256-addressed media blobs (Range support) | 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 podsteadr), 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** — podsteadr 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; podsteadr 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 v6 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 podsteadr 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 6.x config** (`blossom/config.yml`): `storage.rules` are both the upload allowlist (unmatched MIME → 415) and the prune policy — a blob is deleted once unaccessed for longer than its rule's `expiration` (required; no "never", so podcast media gets `100 years`). Without a config file 6.x boots on schema defaults that prune video after a week, so compose sets `BLOSSOM_REQUIRE_CONFIG=1` to make it refuse to start instead. (4.x's pruner had the cutoff inverted; its `rules:` also had to be nested under `storage:` or every upload failed 401.) 2. **Range support needs blossom-server ≥ 6.** 4.4.1 answered `Range:` with 200 + a chunked full body (no Content-Length), breaking seeking and iOS/Apple Podcasts playback; upstream fixed it in 4.6.0 but never published a 4.6+/5.x image. 6.x migrates a 4.x `sqlite.db` in place on first start (backup at `data/sqlite.db.bak`) and keeps the `.` blob files as-is (same MIME→ext mapping, e.g. audio/mpeg → `.mpga`). Uploads now return 201 (was 200). **After migrating, run `scripts/fix-blossom-sizes.py --apply` with blossom stopped**: 4.4.1 sometimes stored a wrong blob size (0 or partial), which 4.x ignored but 6.x sends as Content-Length — those blobs come back empty or truncated (4 of 25 on podsteadr.atobitcoin.io's data, all small txt/jpeg; verified on a copy on redtailhawk, 2026-10-19). 6.x builds the upload response's `url` from the Host header only, so behind a path prefix (`…/blossom`) it's wrong — podsteadr never uses it (it builds enclosure URLs itself). Blobs are served `Cache-Control: immutable` for a year, so behind Cloudflare a DELETEd blob keeps being served from the edge until purged. Interim fix before the upgrade was an nginx gateway (git history: `blossom/gateway.conf.template`). 3. **Split-horizon blossom URL**: browsers use `http://:8098`, but inside the podsteadr 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). 7. **Archipelago hosts mount `/var/tmp` read-only**, so rootless `podman-compose build` and image pulls fail with "creating a temporary directory … read-only file system". Run them with `TMPDIR=$HOME/.cache/podman-build-tmp`. Recreating `podsteadr-blossom` also needs `podsteadr-app` removed first (podman container dependency). If containers can't resolve each other, check that `aardvark-dns` runs in the current rootless netns (seen on redtailhawk, 2026-10-19: it was stranded in a stale one since boot); killing it lets the next podman network op restart it correctly. ## Remaining work - **Archipelago packaging — done (2026-08-07)**, on the archy repo branch `feat/podsteadr-app-package` (not yet merged/pushed): `apps/podsteadr/manifest.yml` (`container.build` from this repo's own Dockerfile, following the `apps/indeedhub` externally-sourced-app pattern) + `apps/podsteadr-mediamtx` + `apps/podsteadr-blossom`, all three on a dedicated `podsteadr-net` bridge network per the `apps/indeedhub-*` sibling-manifest pattern. Bind volumes under `/var/lib/archipelago/`. Ports **8095, 1935, 8889, 8189/udp, 8890, 8098** added to `apps/PORTS.md` (chosen 2026-07-10 to avoid fleet collisions — 8888 is searxng, hence HLS on 8890), all declared `auth: none` with a rationale (public podcast/livestream server — RSS/HLS/blob reads must stay reachable with no Archipelago session; podsteadr already gates its own sensitive routes via NIP-98). `interfaces.main` → port 8095. Passes `scripts/validate-app-manifest.sh` and `cargo test -p archipelago-container manifest` in archy. Not yet verified against a real node install — the `data_uid`/capabilities guesses for blossom and mediamtx (both root-running images writing to fresh bind mounts) are flagged inline as unverified. - Separately, podsteadr is also registered in archy's neode-ui dashboard as an *external* identity-aware app (bookmark to the standalone podsteadr.atobitcoin.io instance + NIP-07 bridge), on archy branch `feat/podsteadr-external-nostr-identity` — a lighter integration than the installable package above, for the already-hosted instance. The two are complementary, not overlapping. - Git remote: ngit `nostr://npub1krldcq2laqxeslseue8uyk24vg6mhmv0ra6ldc52hyfwdx5azd8se5j65x/relay.ngit.dev/podsteadr` is the source of truth; Gitea is kept as a mirror. - 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), SSE instead of 4 s polling on the stream dashboard.