Author SHA1 Message Date
ssmithxandClaude Sonnet 5 db8eb27e5f fix(podcasts): serialize explicit as a real boolean in API responses
SQLite has no boolean type, so raw rows return explicit as 0/1. The
podcast edit form round-trips whatever GET /api/podcasts/:id sends it,
and the update schema requires z.boolean() — so saving any change
without also touching the explicit checkbox failed validation with
"Expected boolean, received number".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 15:42:02 +00:00
ssmithxandClaude Sonnet 5 5ebea55353 feat(episodes): let owners remove episodes from the RSS feed
Adds an "unlisted" flag on episodes rather than reusing the existing
hard-delete route, since a hard delete cascades to purchases/earnings
(ON DELETE CASCADE) and would wipe a producer's sales history and any
unwithdrawn earnings for that episode. Unlisting only affects feed.xml
output — the episode, its purchases, and reseller listings all stay
intact and it can be relisted at any time.

Wires up the missing frontend for it too: the podcast settings page
had no episode list or management controls at all before this, despite
the backend already exposing full episode CRUD.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 21:14:23 +00:00
ssmithxandClaude Sonnet 5 69241a28b1 feat(auth): add an admin-managed login allowlist
Lets the admin restrict which pubkeys may log in, enforced server-side
at /api/auth/login before a session is issued. Disabled by default;
the admin and the bootstrap (no-admin-claimed-yet) case always pass.
Manageable via the existing settings UI/API (npub or hex, one per line).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 18:33:21 +00:00
ssmithx b4c2214c7a fix: surface NIP-07 signature wait as a distinct upload phase
Episode uploads that looked 'stuck uploading to blossom' were actually
stuck waiting on the browser nostr extension's own approval popup —
confirmed via server/blossom logs showing zero incoming requests for
the reported attempt, meaning the upload never left the browser.

The UI flipped to phase 'uploading' (0%) before the NIP-07 signEvent()
call resolved, so a user who didn't notice the extension's popup saw a
progress bar frozen at 0% with no indication anything needed their
action. Added a 'signing' phase with an explicit message telling them
to check for the popup.
2026-08-11 11:50:05 +00:00
ssmithx 17e3ac747c fix: switch HLS back to lowLatency — mpegts can't mux VP8/VP9 from browser publish
mpegts (moved to earlier for OBS B-frame stability) only supports H264.
Browser (WHIP) publishing sends VP8 or VP9 depending on the machine's
available encoders, which mpegts can't mux at all — confirmed live,
'the MPEG-TS variant of HLS supports H264 video only' crash loop.
lowLatency supports the broader codec set browser publishing actually
needs.

Real risk, not resolved by this alone: lowLatency is what caused the
original OBS B-frame muxer crashes this session already fixed once by
moving to mpegts. If that recurs, the correct fix is two MediaMTX
instances (one per hlsVariant) since it's a global, not per-path,
setting — not flip-flopping between the two.
2026-08-11 11:45:13 +00:00
ssmithx 9ca1cb457d fix: try VP9/AV1 too, not just H264, and fail loudly if none available
A real test with the H264-preference fix still negotiated VP8 —
RTCRtpSender.getCapabilities('video') apparently didn't list H264 on
that browser/machine (Chrome's H264 encoder is a separate downloadable
component, not guaranteed present). Confirmed via mediamtx's live path
list: still 'tracks: ["VP8"]' after the fix was deployed.

Now tries every MediaMTX-HLS-supported codec in priority order (H264,
VP9, AV1) instead of only H264, and throws a clear error instead of
silently falling back to VP8 if literally none of them are available —
better than a stream that looks live but can never produce working
HLS.
2026-08-11 11:04:44 +00:00
ssmithx b1493d6792 fix: prefer H264 for browser (WHIP) publishing — VP8 breaks HLS output
Confirmed live in mediamtx logs: the HLS muxer for a browser-published
stream gets created then immediately destroyed — 'the stream doesn't
contain any supported codec, which are currently AV1, VP9, H265, H264,
Opus, MPEG-4 Audio, KLV'. Chrome's getUserMedia()/getDisplayMedia()
default video codec for WebRTC is VP8, which isn't in that list. The
WHIP publish itself succeeds (stream correctly shows live, mediamtx's
own API confirms bytes arriving) so this was easy to miss — only
hls/live/<id>/index.m3u8 silently 404s with 'muxer is waiting to be
created' forever.

Fixed with RTCRtpTransceiver.setCodecPreferences(), reordering the
video codec list so H264 is offered first — falls through safely if
unavailable. Matches what OBS already sends over RTMP, so recording and
HLS both stay on the one already-tested codec instead of gaining a
second, broken one.
2026-08-11 10:31:52 +00:00
ssmithx 2f3a489a8a fix: WHIP browser-publish silently never sends media behind Cloudflare
MTX_WEBRTCADDITIONALHOSTS (the ICE host candidate MediaMTX advertises
for WebRTC/WHIP) was wired to PUBLIC_HOST, the Cloudflare-proxied
domain. The WHIP HTTP handshake (SDP offer/answer through nginx) still
succeeds through Cloudflare, so 'stream from this browser' looks like
it works — but the actual media is a raw UDP path (port 8189) that
Cloudflare never forwards regardless of port, same as the earlier RTMP
issue. The browser ends up trying to send video/audio to Cloudflare's
edge, which drops it, so nothing ever actually arrives.

Split into a dedicated MEDIAMTX_WEBRTC_HOST env var (raw origin IP in
production) instead of reusing PUBLIC_HOST, mirroring how
MEDIAMTX_RTMP_PUBLIC already does this for the same reason. Confirmed
via podman inspect that the previously-deployed container really was
resolving MTX_WEBRTCADDITIONALHOSTS to the Cloudflare-proxied hostname
before this fix.
2026-08-11 10:08:42 +00:00
ssmithx 50d66fa2ea chore: bump MediaMTX to 1.20.0
Released 2026-08-05. Fixes land directly on paths podsteadr uses: HLS
muxer recomputes AAC PTS in MPEG-TS segments (iOS playback precision,
relevant since hlsVariant: mpegts), fixes a goroutine leak during HLS
part rotation, fixes a Chrome WebRTC "packet lost" false-positive and
non-deterministic WebRTC track ordering (WHIP ingest path), and fixes
OBS multitrack RTMP URL parsing. No breaking config changes; the new
opt-in features (native forwarding, MoQ draft support) don't affect
this config (moq: no already).
2026-08-07 23:46:36 +00:00
ssmithx cedfc9f99c docs: record Archipelago app packaging as done
apps/podsteadr, apps/podsteadr-mediamtx, apps/podsteadr-blossom manifests
now exist on the archy repo's feat/podsteadr-app-package branch, per the
guidelines in archy's docs/app-developer-guide.md. Updates the "Remaining
work" list accordingly and notes the separate, complementary external-app
dashboard bookmark integration on feat/podsteadr-external-nostr-identity.
2026-08-07 14:57:57 +00:00
ssmithx 1ca688adda fix(ui): display npub instead of raw hex for the logged-in identity
App.vue fell back to the first 8 hex chars of the pubkey whenever
displayName wasn't set (the common case right after nostr sign-in,
before any kind-0 metadata has been fetched) — indistinguishable
between different identities at a glance, and not a form anyone
recognizes as "their" nostr identity. nostr-tools was already a
frontend dependency; just wasn't used for npub encoding anywhere.

Full hex pubkey is still available via a title tooltip on hover.
2026-08-02 16:20:23 +00:00
ssmithx 133558d923 feat(auth): bridge NIP-07 sign-in to Archipelago's identity manager
Vendors Archipelago's NIP-07 provider shim (neode-ui/public/
nostr-provider.js) and loads it in index.html's <head>. It's a no-op
outside an Archipelago iframe (the shim's own window === window.top
guard), so this is always safe to include.

When podsteadr is opened from the Archipelago dashboard (registered
there as an external identity-aware app — see the companion archy
change on branch feat/podsteadr-external-nostr-identity), the parent
frame lets the user pick one of their node's stored nostr identities
and posts window.nostr signing requests through to it. The shim then
runs the existing NIP-98 flow against our own auth (nip07.ts, auth.ts,
routes/auth.ts) exactly as if a browser extension had signed it —
nothing on the server needed to change.

Configured for our actual auth shape via data-* attrs the shim reads
from its own <script> tag: data-session-url="/api/auth/login" (ours,
not indeedhub's /api/auth/nostr/session), data-session-mode="cookie"
(we set a session cookie via @fastify/cookie rather than returning a
bearer token in JSON — the shim previously only knew the token shape),
data-me-url="/api/auth/me" (skip re-running the handshake if already
signed in), data-health-url="/api/health" (our actual health route).

Not wired through an Archipelago app manifest/hook — podsteadr isn't
an orchestrator-managed package, so this copy of nostr-provider.js
won't auto-update with archy OTA releases. Re-sync by hand from
archy/neode-ui/public/nostr-provider.js if that file changes upstream.

Verified: `npm run build` (vue-tsc + vite) clean, dist/index.html
includes the script tag with all four data-* attrs, dist/
nostr-provider.js present and syntactically valid.
2026-08-02 14:44:00 +00:00
20 changed files with 653 additions and 34 deletions
+8
View File
@@ -10,6 +10,14 @@ MEDIAMTX_WHIP_PUBLIC=http://${PUBLIC_HOST}:8889
MEDIAMTX_HLS_PUBLIC=http://${PUBLIC_HOST}:8890
BLOSSOM_URL_DEFAULT=http://${PUBLIC_HOST}:8098
# ICE host candidate MediaMTX advertises for WebRTC/WHIP (browser-publish
# "stream from this browser"). If you're behind Cloudflare or similar
# HTTP(S)-only proxy, this MUST be the raw origin IP, not PUBLIC_HOST —
# Cloudflare never forwards raw UDP, so a proxied hostname here makes the
# WHIP handshake succeed while media silently never arrives. Same reasoning
# as MEDIAMTX_RTMP_PUBLIC above. Plain host/IP, no scheme or port.
MEDIAMTX_WEBRTC_HOST=${PUBLIC_HOST}
# Default nostr relays for NIP-53 live-event announcements (comma separated,
# changeable at runtime in Settings)
NOSTR_RELAYS=wss://relay.damus.io,wss://nos.lol,wss://relay.nostr.band
+11 -3
View File
@@ -35,7 +35,7 @@ services:
- blossom
mediamtx:
image: docker.io/bluenviron/mediamtx:1.19.2
image: docker.io/bluenviron/mediamtx:1.20.0
container_name: podsteadr-mediamtx
restart: unless-stopped
ports:
@@ -44,8 +44,16 @@ services:
- "8189:8189/udp" # WebRTC ICE
- "8890:8888" # HLS (host 8890; 8888 kept free for other apps)
environment:
# Browsers need a reachable ICE host candidate; set PUBLIC_HOST in .env
MTX_WEBRTCADDITIONALHOSTS: ${PUBLIC_HOST:-localhost}
# Browsers need a reachable ICE host candidate for the actual UDP media
# path (browser-publish "stream from this browser" / WHIP). This must
# be the raw origin IP, NOT PUBLIC_HOST — Cloudflare's proxy only
# forwards HTTP(S), never raw UDP, regardless of port (same reason
# MEDIAMTX_RTMP_PUBLIC above uses the raw IP instead of the
# Cloudflare-proxied domain). Using PUBLIC_HOST here means the browser
# resolves the ICE candidate to Cloudflare's edge and the WHIP HTTP
# handshake succeeds while media silently never arrives — set
# MEDIAMTX_WEBRTC_HOST in .env.
MTX_WEBRTCADDITIONALHOSTS: ${MEDIAMTX_WEBRTC_HOST:-localhost}
volumes:
- ./mediamtx/mediamtx.yml:/mediamtx.yml:ro
- mediamtx-recordings:/recordings
+24 -9
View File
@@ -44,7 +44,7 @@ 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.19.2` | 1935 (RTMP), 8889 (WHIP), 8189/udp (ICE), 8890→8888 (HLS) | ingest + HLS output + recording |
| `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:4` (4.4.1) | 8098→3000 | sha256-addressed media blobs |
Key flows:
@@ -153,14 +153,29 @@ frontend/ # Vue 3 + Vite + Tailwind + Pinia
## Remaining work
- **Archipelago packaging** (the original deployment target, archy repo):
`apps/podsteadr/manifest.yml` + `apps/podsteadr-mediamtx` + `apps/podsteadr-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?).
- **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/<app>`. 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: `http://146.59.87.168:3000/ssmithx/podsteadr.git` (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).
+5
View File
@@ -4,6 +4,11 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>podsteadr</title>
<!-- No-op outside an Archipelago iframe (see nostr-provider.js's own
window === window.top guard) — safe to always include. Provides
window.nostr + auto sign-in via the node's selected nostr identity
when opened inside the Archipelago shell. -->
<script src="/nostr-provider.js" data-session-url="/api/auth/login" data-session-mode="cookie" data-me-url="/api/auth/me" data-health-url="/api/health"></script>
</head>
<body>
<div id="app"></div>
+217
View File
@@ -0,0 +1,217 @@
/**
* NIP-07 Nostr Provider Shim — Archipelago
*
* Vendored from archy/neode-ui/public/nostr-provider.js (generalized version).
* Provides window.nostr (NIP-07) for iframe apps launched inside the
* Archipelago shell, bridging signing requests via postMessage to the
* parent frame, which relays them to the Archipelago node's identity
* manager. Auto sign-in: does NIP-98 auth against this app's own backend,
* then reloads so the app picks up the valid session.
*
* Not vendored via an Archipelago manifest hook (podsteadr isn't an
* orchestrator-managed package — see neode-ui's EXTERNAL_URLS /
* WEB_ONLY_APP-style "external web app" registration instead), so this
* copy won't auto-update with archy's OTA releases. Re-sync by hand from
* archy/neode-ui/public/nostr-provider.js if that file changes.
*/
(function () {
'use strict';
if (window.__archipelagoNostr) return;
window.__archipelagoNostr = true;
if (window === window.top) return;
var pending = {}, nextId = 1;
function request(method, params) {
return new Promise(function (resolve, reject) {
var id = nextId++;
pending[id] = { resolve: resolve, reject: reject };
window.parent.postMessage({ type: 'nostr-request', id: id, method: method, params: params || {} }, '*');
setTimeout(function () { if (pending[id]) { pending[id].reject(new Error('NIP-07 timeout')); delete pending[id]; } }, 30000);
});
}
window.addEventListener('message', function (e) {
if (!e.data || e.data.type !== 'nostr-response') return;
var h = pending[e.data.id]; if (!h) return; delete pending[e.data.id];
e.data.error ? h.reject(new Error(e.data.error)) : h.resolve(e.data.result);
});
window.nostr = {
getPublicKey: function () { return request('getPublicKey'); },
signEvent: function (ev) { return request('signEvent', { event: ev }); },
sign: function (ev) { return request('signEvent', { event: ev }); },
getRelays: function () { return request('getRelays'); },
nip04: {
encrypt: function (pk, pt) { return request('nip04.encrypt', { pubkey: pk, plaintext: pt }); },
decrypt: function (pk, ct) { return request('nip04.decrypt', { pubkey: pk, ciphertext: ct }); },
},
nip44: {
encrypt: function (pk, pt) { return request('nip44.encrypt', { pubkey: pk, plaintext: pt }); },
decrypt: function (pk, ct) { return request('nip44.decrypt', { pubkey: pk, ciphertext: ct }); },
},
};
// --- Loading Overlay ---
var overlay = null;
function showLoader(message) {
if (overlay) return;
overlay = document.createElement('div');
overlay.id = 'archipelago-auth-overlay';
overlay.innerHTML =
'<div style="display:flex;flex-direction:column;align-items:center;gap:16px;">' +
'<svg width="40" height="40" viewBox="0 0 24 24" fill="none" style="animation:archy-spin 1s linear infinite">' +
'<circle cx="12" cy="12" r="10" stroke="rgba(255,255,255,0.2)" stroke-width="3"/>' +
'<path d="M12 2a10 10 0 019.95 9" stroke="#fb923c" stroke-width="3" stroke-linecap="round"/>' +
'</svg>' +
'<div style="color:rgba(255,255,255,0.9);font:500 14px/1.4 -apple-system,system-ui,sans-serif">' + (message || 'Signing in...') + '</div>' +
'</div>';
overlay.style.cssText = 'position:fixed;inset:0;z-index:99999;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,0.7);backdrop-filter:blur(8px);';
var style = document.createElement('style');
style.textContent = '@keyframes archy-spin{to{transform:rotate(360deg)}}';
document.head.appendChild(style);
document.body.appendChild(overlay);
}
function updateLoader(message) {
if (!overlay) return;
var txt = overlay.querySelector('div > div');
if (txt) txt.textContent = message;
}
function hideLoader() {
if (overlay) { overlay.remove(); overlay = null; }
}
// --- Per-app config (data-* attrs on the injected <script> tag). Defaults
// match indeedhub's original hardcoded values, so apps that don't set any
// overrides keep behaving exactly as before.
var scriptEl = document.currentScript;
var ds = (scriptEl && scriptEl.dataset) || {};
var cfg = {
healthUrl: ds.healthUrl || '/api/nostr-auth/health',
sessionUrl: ds.sessionUrl || '/api/auth/nostr/session',
sessionMethod: ds.sessionMethod || 'POST',
// 'token' (default): login response is JSON {accessToken, refreshToken};
// stored in sessionStorage, matches indeedhub.
// 'cookie': server sets the session cookie directly on the login
// response (Set-Cookie) — nothing to store client-side, just reload.
sessionMode: ds.sessionMode || 'token',
// Optional: for cookie-mode apps, check this endpoint first and skip
// the NIP-98 handshake entirely if it reports already-authenticated
// (401 otherwise) — avoids re-running sign-in on every iframe reload.
meUrl: ds.meUrl || null,
};
// --- Direct NIP-98 Auth ---
var authDone = false;
function performNip98Auth(pubkey) {
var healthUrl = window.location.origin + cfg.healthUrl;
var sessionUrl = window.location.origin + cfg.sessionUrl;
// 1. Check if API backend is reachable (3s timeout)
var hc = new AbortController();
var ht = setTimeout(function () { hc.abort(); }, 3000);
fetch(healthUrl, { signal: hc.signal }).then(function (r) {
clearTimeout(ht);
if (!r.ok) throw new Error('Health ' + r.status);
// 2. API is up — show loader and do NIP-98
showLoader('Signing in with Nostr...');
var now = Math.floor(Date.now() / 1000);
var event = {
kind: 27235, created_at: now, content: '', pubkey: pubkey,
tags: [['u', sessionUrl], ['method', cfg.sessionMethod]]
};
console.log('[nostr-provider] NIP-98: signing for', sessionUrl);
return window.nostr.signEvent(event);
}).then(function (signed) {
updateLoader('Creating session...');
var ac = new AbortController();
setTimeout(function () { ac.abort(); }, 10000);
return fetch(sessionUrl, {
method: cfg.sessionMethod,
headers: { 'Authorization': 'Nostr ' + btoa(JSON.stringify(signed)) },
signal: ac.signal
});
}).then(function (res) {
console.log('[nostr-provider] NIP-98: response', res.status);
if (!res.ok) throw new Error('Auth failed: ' + res.status);
if (cfg.sessionMode === 'cookie') {
// Session cookie already landed via Set-Cookie on this response.
updateLoader('Signed in! Loading...');
console.log('[nostr-provider] NIP-98: success (cookie session), reloading...');
setTimeout(function () { window.location.reload(); }, 400);
return null;
}
return res.json();
}).then(function (data) {
if (!data) return; // cookie-mode: handled above, nothing left to do
if (data.accessToken) {
sessionStorage.setItem('nostr_token', data.accessToken);
sessionStorage.setItem('nostr_pubkey', pubkey);
if (data.refreshToken) sessionStorage.setItem('refresh_token', data.refreshToken);
updateLoader('Signed in! Loading...');
console.log('[nostr-provider] NIP-98: success, reloading...');
setTimeout(function () { window.location.reload(); }, 400);
} else {
hideLoader(); authDone = false;
}
}).catch(function (err) {
hideLoader(); authDone = false;
var msg = err.message || String(err);
if (msg.indexOf('abort') > -1) msg = 'API timeout';
console.warn('[nostr-provider] NIP-98 skipped:', msg);
});
}
function doNip98Auth(pubkey) {
if (authDone) return;
authDone = true;
if (cfg.meUrl) {
// Already-authenticated check first — avoids re-running the NIP-98
// handshake (and its reload) on every iframe load for cookie-session
// apps, where there's no client-visible token to check locally.
fetch(window.location.origin + cfg.meUrl, { credentials: 'same-origin' })
.then(function (r) {
if (r.ok) {
console.log('[nostr-provider] Already authenticated (meUrl ok), skipping NIP-98');
authDone = false;
return;
}
performNip98Auth(pubkey);
})
.catch(function () { performNip98Auth(pubkey); });
return;
}
performNip98Auth(pubkey);
}
// Listen for identity from parent Archipelago frame
window.addEventListener('message', function (e) {
if (!e.data || e.data.type !== 'archipelago:identity') return;
var pk = e.data.nostr_pubkey;
console.log('[nostr-provider] Identity received:', pk ? pk.slice(0, 12) + '...' : 'none');
if (!pk) return;
// Skip if already signed in with a real token (not mock)
try {
var token = sessionStorage.getItem('nostr_token');
if (token && token.indexOf('mock-') === -1) {
console.log('[nostr-provider] Already signed in with real token');
return;
}
} catch (x) {}
setTimeout(function () { doNip98Auth(pk); }, 1500);
});
})();
+16 -1
View File
@@ -1,10 +1,25 @@
<script setup lang="ts">
import { computed } from 'vue';
import { nip19 } from 'nostr-tools';
import { useAuthStore } from './stores/auth';
import { useRouter } from 'vue-router';
const auth = useAuthStore();
const router = useRouter();
// Hex pubkeys look identical at a glance across identities — npub is the
// standard nostr display form and is what users actually recognize.
const identityLabel = computed(() => {
if (auth.displayName) return auth.displayName;
if (!auth.pubkey) return '';
try {
const npub = nip19.npubEncode(auth.pubkey);
return npub.slice(0, 12) + '…' + npub.slice(-6);
} catch {
return auth.pubkey.slice(0, 8) + '…';
}
});
async function logout() {
await auth.logout();
router.push('/login');
@@ -23,7 +38,7 @@ async function logout() {
<RouterLink to="/earnings" class="hover:text-orange-400">Earnings</RouterLink>
<RouterLink to="/settings" class="hover:text-orange-400">Settings</RouterLink>
<button class="btn-secondary !px-3 !py-1" @click="logout">
<span class="max-w-[8rem] truncate font-mono text-xs">{{ auth.displayName || auth.pubkey.slice(0, 8) + '…' }}</span>
<span class="max-w-[8rem] truncate font-mono text-xs" :title="auth.pubkey ?? undefined">{{ identityLabel }}</span>
Logout
</button>
</nav>
+5
View File
@@ -24,7 +24,12 @@ export async function uploadToBlossom(
file: File,
sha256: string,
onProgress?: (frac: number) => void,
onSigning?: () => void,
): Promise<BlossomUpload> {
// signEvent() waits on the NIP-07 extension's own approval popup, which can
// take an unbounded amount of time (or go unnoticed) — tell the caller so
// the UI doesn't say "Uploading… 0%" while nothing has actually started.
onSigning?.();
const now = Math.floor(Date.now() / 1000);
const auth = await nip07().signEvent({
kind: 24242,
+33 -1
View File
@@ -10,9 +10,41 @@ export async function publishWhip(
bearer: string,
stream: MediaStream,
): Promise<WhipSession> {
// MediaMTX's HLS output only muxes AV1, VP9, H265, H264, Opus, MPEG-4
// Audio, or KLV (confirmed live in its logs: "the stream doesn't contain
// any supported codec" — the muxer gets created then immediately
// destroyed, so the WHIP publish itself still succeeds and the stream
// shows as live, but hls/live/<id>/index.m3u8 permanently 404s with
// "muxer is waiting to be created"). Browsers default to VP8 for
// getUserMedia/getDisplayMedia video, which isn't in that list at all.
// Reordering codec preference to H264 first didn't actually change what
// got negotiated on a real test (RTCRtpSender.getCapabilities('video')
// apparently didn't list H264 on that browser/machine — Chrome's H264
// encoder is a separate downloadable component and isn't guaranteed
// present) — so try every MediaMTX-supported codec in priority order
// instead of only H264, and fail loudly if literally none of them are
// available rather than silently falling back to the broken default.
const MEDIAMTX_HLS_VIDEO_CODECS = ['video/H264', 'video/VP9', 'video/AV1'];
const pc = new RTCPeerConnection();
for (const track of stream.getTracks()) {
pc.addTransceiver(track, { direction: 'sendonly' });
const transceiver = pc.addTransceiver(track, { direction: 'sendonly' });
if (track.kind === 'video' && typeof transceiver.setCodecPreferences === 'function') {
const capabilities = RTCRtpSender.getCapabilities('video');
const available = capabilities?.codecs ?? [];
const preferred = MEDIAMTX_HLS_VIDEO_CODECS.flatMap((mime) =>
available.filter((c) => c.mimeType.toLowerCase() === mime.toLowerCase()),
);
if (preferred.length === 0) {
pc.close();
throw new Error(
"This browser doesn't support any video codec MediaMTX can turn into HLS " +
`(needs one of: ${MEDIAMTX_HLS_VIDEO_CODECS.join(', ')}). Try a different browser, or use OBS instead.`,
);
}
const rest = available.filter((c) => !preferred.includes(c));
transceiver.setCodecPreferences([...preferred, ...rest]);
}
}
const offer = await pc.createOffer();
+63 -1
View File
@@ -4,18 +4,30 @@ import { useRoute, useRouter } from 'vue-router';
import { api } from '../lib/api';
import PodcastForm, { type PodcastPayload } from '../components/PodcastForm.vue';
interface Episode {
id: string;
title: string;
pub_date: number;
unlisted: number;
}
const route = useRoute();
const router = useRouter();
const podcastId = route.params.id as string;
const podcast = ref<(PodcastPayload & { id: string }) | null>(null);
const episodes = ref<Episode[]>([]);
const loading = ref(true);
const error = ref('');
const saved = ref(false);
const episodeError = ref('');
const busyEpisodeId = ref('');
onMounted(async () => {
try {
podcast.value = await api.get<PodcastPayload & { id: string }>(`/api/podcasts/${podcastId}`);
const data = await api.get<PodcastPayload & { id: string; episodes: Episode[] }>(`/api/podcasts/${podcastId}`);
podcast.value = data;
episodes.value = data.episodes;
} catch (err) {
error.value = (err as Error).message;
} finally {
@@ -27,6 +39,23 @@ function onSaved(): void {
saved.value = true;
setTimeout(() => router.push('/'), 800);
}
async function toggleListed(ep: Episode): Promise<void> {
const nextUnlisted = !ep.unlisted;
if (nextUnlisted && !confirm(`Remove "${ep.title}" from the RSS feed? It stays in your library and can be added back later.`)) {
return;
}
episodeError.value = '';
busyEpisodeId.value = ep.id;
try {
const updated = await api.put<Episode>(`/api/podcasts/${podcastId}/episodes/${ep.id}`, { unlisted: nextUnlisted });
ep.unlisted = updated.unlisted;
} catch (err) {
episodeError.value = (err as Error).message;
} finally {
busyEpisodeId.value = '';
}
}
</script>
<template>
@@ -40,5 +69,38 @@ function onSaved(): void {
Saved.
</p>
</div>
<div v-if="!loading && !error" class="card">
<h2 class="mb-1 text-lg font-semibold">Episodes</h2>
<p class="mb-4 text-xs text-white/30">
Removing an episode from the feed hides it from RSS/podcast apps but keeps it in your
library — sales history, reseller listings, and the uploaded file are untouched, and you
can add it back any time.
</p>
<p v-if="episodeError" class="mb-3 rounded-lg bg-red-500/20 border border-red-500/40 p-3 text-sm text-red-200">
{{ episodeError }}
</p>
<p v-if="!episodes.length" class="text-sm text-white/50">No episodes yet.</p>
<ul v-else class="space-y-2">
<li v-for="ep in episodes" :key="ep.id" class="flex items-center justify-between gap-4 rounded-lg bg-white/5 p-3">
<div class="min-w-0">
<p class="truncate font-medium" :class="{ 'text-white/40': ep.unlisted }">{{ ep.title }}</p>
<p class="text-xs text-white/30">
{{ new Date(ep.pub_date * 1000).toLocaleDateString() }}
<span v-if="ep.unlisted" class="ml-2 rounded-full bg-amber-500/20 px-2 py-0.5 text-amber-300">
Removed from feed
</span>
</p>
</div>
<button
class="btn-secondary shrink-0 whitespace-nowrap !py-1.5 !px-3 text-sm"
:disabled="busyEpisodeId === ep.id"
@click="toggleListed(ep)"
>
{{ busyEpisodeId === ep.id ? 'Working…' : ep.unlisted ? 'Add back to feed' : 'Remove from feed' }}
</button>
</li>
</ul>
</div>
</div>
</template>
+56 -1
View File
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue';
import { nip19 } from 'nostr-tools';
import { api } from '../lib/api';
import { useAuthStore } from '../stores/auth';
@@ -8,28 +9,64 @@ interface Settings {
relays: string[];
public_url: string;
admin_pubkey: string | null;
login_allowlist_enabled: boolean;
login_allowlist: string[];
}
const auth = useAuthStore();
const form = reactive({ blossom_url: '', relays: '', public_url: '' });
const form = reactive({
blossom_url: '',
relays: '',
public_url: '',
login_allowlist_enabled: false,
login_allowlist: '',
});
const saved = ref(false);
const error = ref('');
/** Accepts npub or raw hex, one per line; returns lowercase hex. Throws on anything invalid. */
function parseAllowlist(text: string): string[] {
return text
.split('\n')
.map((l) => l.trim())
.filter(Boolean)
.map((line) => {
if (line.startsWith('npub1')) {
const decoded = nip19.decode(line);
if (decoded.type !== 'npub') throw new Error(`not an npub: ${line}`);
return decoded.data;
}
if (!/^[0-9a-f]{64}$/i.test(line)) throw new Error(`not a valid npub or hex pubkey: ${line}`);
return line.toLowerCase();
});
}
onMounted(async () => {
const s = await api.get<Settings>('/api/settings');
form.blossom_url = s.blossom_url;
form.relays = s.relays.join('\n');
form.public_url = s.public_url;
form.login_allowlist_enabled = s.login_allowlist_enabled;
form.login_allowlist = s.login_allowlist.map((pk) => nip19.npubEncode(pk)).join('\n');
});
async function save() {
error.value = '';
saved.value = false;
let allowlist: string[];
try {
allowlist = parseAllowlist(form.login_allowlist);
} catch (err) {
error.value = (err as Error).message;
return;
}
try {
await api.put('/api/settings', {
blossom_url: form.blossom_url,
relays: form.relays.split('\n').map((r) => r.trim()).filter(Boolean),
public_url: form.public_url,
login_allowlist_enabled: form.login_allowlist_enabled,
login_allowlist: allowlist,
});
saved.value = true;
} catch (err) {
@@ -62,6 +99,24 @@ async function save() {
<input id="set-public" v-model="form.public_url" class="input" type="url" required />
<p class="mt-1 text-xs text-white/30">Used in RSS feed links. Must be reachable by podcast apps.</p>
</div>
<div class="border-t border-white/10 pt-4">
<label class="flex items-center gap-2 text-sm">
<input v-model="form.login_allowlist_enabled" type="checkbox" />
Restrict logins to an allowlist
</label>
<p class="mt-1 text-xs text-white/30">
When enabled, only the admin and pubkeys listed below can log in. Everyone else is
rejected at login (existing sessions aren't revoked).
</p>
<textarea
id="set-allowlist"
v-model="form.login_allowlist"
class="input mt-2 font-mono text-sm"
rows="6"
placeholder="npub1... (one per line, npub or hex)"
:disabled="!form.login_allowlist_enabled"
/>
</div>
<p v-if="!auth.isAdmin" class="rounded-lg bg-amber-500/20 border border-amber-500/40 p-3 text-sm text-amber-200">
Only the admin (first account to log in) can change settings.
</p>
+12 -3
View File
@@ -17,7 +17,7 @@ const podcast = ref<PodcastSummary | null>(null);
const creatingNew = ref(false);
const file = ref<File | null>(null);
const phase = ref<'idle' | 'hashing' | 'uploading' | 'registering'>('idle');
const phase = ref<'idle' | 'hashing' | 'signing' | 'uploading' | 'registering'>('idle');
const progress = ref(0);
const error = ref('');
@@ -31,6 +31,7 @@ const episodeUrl = ref('');
const phaseLabel = computed(() => ({
idle: '',
hashing: 'Computing sha256…',
signing: 'Waiting for your nostr extension to approve the upload — check for a popup (it may be behind this window).',
uploading: `Uploading to Blossom… ${(progress.value * 100).toFixed(0)}%`,
registering: 'Publishing episode…',
}[phase.value]));
@@ -72,9 +73,17 @@ async function publish() {
durationSecs.value = await probeDuration(file.value);
const sha = await sha256File(file.value);
phase.value = 'uploading';
const blossomUrl = settings.public!.blossomUrl;
await uploadToBlossom(blossomUrl, file.value, sha, (f) => (progress.value = f));
await uploadToBlossom(
blossomUrl,
file.value,
sha,
(f) => {
phase.value = 'uploading';
progress.value = f;
},
() => (phase.value = 'signing'),
);
uploaded.value = { sha256: sha, size: file.value.size };
phase.value = 'registering';
+19 -7
View File
@@ -25,13 +25,22 @@ rtmpAddress: :1935
hls: yes
hlsAddress: :8888
# Standard HLS, not lowLatency: LL-HLS's small per-part buffering window has very little
# tolerance for B-frame reordering (common in most OBS encoder presets), and a real test
# stream crashed the muxer twice in ~2 minutes with "too many reordered frames" / "unable to
# extract DTS" once frame timing got even slightly irregular. Standard HLS buffers a full
# segment before finalizing, which absorbs that jitter — a few extra seconds of latency
# instead of intermittent muxer crashes / viewer buffering.
hlsVariant: mpegts
# Switched from mpegts back to lowLatency (2026-08-11): mpegts can only mux
# H264, and browser (WHIP) publishing sends VP8/VP9 depending on the
# machine's available encoders — confirmed live, mpegts crashed with "the
# MPEG-TS variant of HLS supports H264 video only" for a VP9 browser stream.
# lowLatency supports AV1/VP9/H265/H264/Opus, so it's required for browser
# publishing to produce any HLS output at all.
#
# Known risk: this is the variant that was moved AWAY from earlier — LL-HLS's
# small per-part buffering window has very little tolerance for B-frame
# reordering (common in most OBS encoder presets), and a real OBS test
# stream crashed the muxer twice in ~2 minutes with "too many reordered
# frames" / "unable to extract DTS" once frame timing got even slightly
# irregular. If that recurs, the real fix is running two MediaMTX instances
# (mpegts for RTMP/OBS, lowLatency for WHIP/browser) since hlsVariant is a
# global setting with no per-path override — not flipping back and forth.
hlsVariant: lowLatency
hlsAlwaysRemux: yes
hlsAllowOrigins: ["*"]
@@ -39,6 +48,9 @@ webrtc: yes
webrtcAddress: :8889
webrtcLocalUDPAddress: :8189
webrtcAllowOrigins: ["*"]
# webrtcAdditionalHosts is set via MTX_WEBRTCADDITIONALHOSTS in
# docker-compose.yml (MEDIAMTX_WEBRTC_HOST in .env) — see the comment there
# for why it must be the raw IP, not the Cloudflare-proxied domain.
# ---- recording -----------------------------------------------------------
pathDefaults:
+120
View File
@@ -79,8 +79,81 @@ describe('auth', () => {
});
});
describe('login allowlist', () => {
const sk2 = generateSecretKey();
const pk2 = getPublicKey(sk2);
function nip98Header2(url: string, method: string): string {
const event = finalizeEvent(
{
kind: 27235,
created_at: Math.floor(Date.now() / 1000),
content: '',
tags: [['u', url], ['method', method], ['nonce', Math.random().toString(36).slice(2)]],
},
sk2,
);
return `Nostr ${Buffer.from(JSON.stringify(event)).toString('base64')}`;
}
it('rejects a non-listed pubkey once the allowlist is enabled, admin still logs in', async () => {
const enable = await app.inject({
method: 'PUT',
url: '/api/settings',
headers: { cookie, 'content-type': 'application/json' },
payload: { login_allowlist_enabled: true, login_allowlist: [] },
});
expect(enable.statusCode).toBe(200);
const blocked = await app.inject({
method: 'POST',
url: '/api/auth/login',
headers: { authorization: nip98Header2('http://localhost:8095/api/auth/login', 'POST') },
});
expect(blocked.statusCode).toBe(403);
const adminStillIn = await app.inject({
method: 'POST',
url: '/api/auth/login',
headers: { authorization: nip98Header('http://localhost:8095/api/auth/login', 'POST') },
});
expect(adminStillIn.statusCode).toBe(200);
expect(adminStillIn.json().isAdmin).toBe(true);
});
it('allows a pubkey once it is added to the allowlist', async () => {
const update = await app.inject({
method: 'PUT',
url: '/api/settings',
headers: { cookie, 'content-type': 'application/json' },
payload: { login_allowlist: [pk2] },
});
expect(update.statusCode).toBe(200);
expect(update.json().login_allowlist).toEqual([pk2]);
const res = await app.inject({
method: 'POST',
url: '/api/auth/login',
headers: { authorization: nip98Header2('http://localhost:8095/api/auth/login', 'POST') },
});
expect(res.statusCode).toBe(200);
expect(res.json().pubkey).toBe(pk2);
});
afterAll(async () => {
// Leave the allowlist disabled so later describe blocks aren't affected.
await app.inject({
method: 'PUT',
url: '/api/settings',
headers: { cookie, 'content-type': 'application/json' },
payload: { login_allowlist_enabled: false },
});
});
});
describe('podcasts, episodes, feed', () => {
let podcastId: string;
let episodeId: string;
const sha = 'c'.repeat(64);
it('creates a podcast', async () => {
@@ -98,6 +171,23 @@ describe('podcasts, episodes, feed', () => {
expect(res.statusCode).toBe(201);
podcastId = res.json().id;
expect(res.json().podcast_guid).toMatch(/^[0-9a-f-]{36}$/);
expect(res.json().explicit).toBe(false); // not the raw SQLite 0/1
});
it('lets the edit form round-trip a fetched podcast unmodified (regression: explicit came back as 0/1, not a bool)', async () => {
const fetched = await app.inject({ method: 'GET', url: `/api/podcasts/${podcastId}`, headers: { cookie } });
expect(fetched.json().explicit).toBe(false);
const { episodes: _episodes, feed_url: _feedUrl, ...editForm } = fetched.json();
const res = await app.inject({
method: 'PUT',
url: `/api/podcasts/${podcastId}`,
headers: { cookie },
payload: editForm,
});
expect(res.statusCode).toBe(200);
expect(res.json().lightning_address).toBe('tester@getalby.com');
expect(res.json().explicit).toBe(false);
});
it('registers an episode after verifying the blob on blossom', async () => {
@@ -116,6 +206,7 @@ describe('podcasts, episodes, feed', () => {
});
expect(res.statusCode).toBe(201);
expect(res.json().enclosure_url).toContain(`${sha}.mp4`);
episodeId = res.json().id;
} finally {
vi.unstubAllGlobals();
}
@@ -157,6 +248,35 @@ describe('podcasts, episodes, feed', () => {
});
expect(cached.statusCode).toBe(304);
});
it('removing an episode from the feed hides it from feed.xml but keeps it in the owner list', async () => {
const unlist = await app.inject({
method: 'PUT',
url: `/api/podcasts/${podcastId}/episodes/${episodeId}`,
headers: { cookie },
payload: { unlisted: true },
});
expect(unlist.statusCode).toBe(200);
expect(unlist.json().unlisted).toBe(1);
const feed = await app.inject({ method: 'GET', url: `/feeds/${podcastId}/feed.xml` });
expect(feed.body).not.toContain(`${sha}.mp4`);
const owned = await app.inject({ method: 'GET', url: `/api/podcasts/${podcastId}`, headers: { cookie } });
expect(owned.json().episodes.some((e: { id: string }) => e.id === episodeId)).toBe(true);
const relist = await app.inject({
method: 'PUT',
url: `/api/podcasts/${podcastId}/episodes/${episodeId}`,
headers: { cookie },
payload: { unlisted: false },
});
expect(relist.statusCode).toBe(200);
expect(relist.json().unlisted).toBe(0);
const feedAgain = await app.inject({ method: 'GET', url: `/feeds/${podcastId}/feed.xml` });
expect(feedAgain.body).toContain(`${sha}.mp4`);
});
});
describe('streams + mediamtx auth webhook', () => {
+11
View File
@@ -148,6 +148,17 @@ CREATE TABLE cashu_proofs (
created_at INTEGER NOT NULL
);
CREATE INDEX idx_cashu_proofs_unspent ON cashu_proofs(spent_at);
`,
},
{
id: 3,
sql: `
-- Removing an episode from the RSS feed doesn't have to mean deleting it outright:
-- a hard DELETE cascades to purchases/earnings (ON DELETE CASCADE), which would wipe
-- a producer's sales history and any unwithdrawn earnings for that episode. "unlisted"
-- lets the feed simply omit the episode while everything else (purchases, reseller
-- listings, the blob itself) stays intact and reversible.
ALTER TABLE episodes ADD COLUMN unlisted INTEGER NOT NULL DEFAULT 0;
`,
},
];
+3
View File
@@ -24,6 +24,9 @@ export default async function authRoutes(app: FastifyInstance) {
if (err instanceof Nip98Error) return reply.code(401).send({ error: err.message });
throw err;
}
if (!settings.isLoginAllowed(pubkey)) {
return reply.code(403).send({ error: 'this account is not on the login allowlist' });
}
upsertUser.run(pubkey, nowSecs(), nowSecs());
settings.claimAdminIfUnset(pubkey);
+1 -1
View File
@@ -8,7 +8,7 @@ export default async function feedRoutes(app: FastifyInstance) {
const { db, settings } = app.ctx;
const getPodcast = db.prepare('SELECT * FROM podcasts WHERE id = ?');
const listEpisodes = db.prepare('SELECT * FROM episodes WHERE podcast_id = ? ORDER BY pub_date DESC');
const listEpisodes = db.prepare('SELECT * FROM episodes WHERE podcast_id = ? AND unlisted = 0 ORDER BY pub_date DESC');
const listAllPodcasts = db.prepare('SELECT * FROM podcasts ORDER BY created_at');
app.get('/feeds/:id/feed.xml', async (req, reply) => {
+15 -6
View File
@@ -35,6 +35,7 @@ const episodeSchema = z.object({
episode_no: z.number().int().positive().nullish(),
pub_date: z.number().int().positive().optional(),
price_sats: z.number().int().positive().nullish(),
unlisted: z.boolean().optional(),
});
export default async function podcastRoutes(app: FastifyInstance) {
@@ -50,10 +51,18 @@ export default async function podcastRoutes(app: FastifyInstance) {
return p && p.owner_pubkey === pubkey ? p : null;
}
// SQLite has no boolean type — `explicit` comes back as a raw 0/1 integer.
// The client's edit form round-trips whatever this endpoint sends it, and the
// update schema requires a real boolean, so this needs to be a true boolean
// on the wire or re-submitting an untouched form fails validation.
function serializePodcast(p: Podcast): Omit<Podcast, 'explicit'> & { explicit: boolean } {
return { ...p, explicit: !!p.explicit };
}
app.get('/api/podcasts', { preHandler: app.requireAuth }, async (req) => {
const podcasts = listPodcasts.all(req.userPubkey) as Podcast[];
return podcasts.map((p) => ({
...p,
...serializePodcast(p),
feed_url: `${settings.all().public_url}/feeds/${p.id}/feed.xml`,
}));
});
@@ -74,7 +83,7 @@ export default async function podcastRoutes(app: FastifyInstance) {
d.category, d.explicit ? 1 : 0, d.lightning_address ?? null, d.keysend_node ?? null,
d.value_suggested ?? null, podcastGuidForFeedUrl(feedUrl), nowSecs(), nowSecs(),
);
return reply.code(201).send({ ...(getPodcast.get(id) as Podcast), feed_url: feedUrl });
return reply.code(201).send({ ...serializePodcast(getPodcast.get(id) as Podcast), feed_url: feedUrl });
});
app.get('/api/podcasts/:id', { preHandler: app.requireAuth }, async (req, reply) => {
@@ -82,7 +91,7 @@ export default async function podcastRoutes(app: FastifyInstance) {
const p = ownedPodcast(id, req.userPubkey!);
if (!p) return reply.code(404).send({ error: 'podcast not found' });
return {
...p,
...serializePodcast(p),
feed_url: `${settings.all().public_url}/feeds/${p.id}/feed.xml`,
episodes: listEpisodes.all(id) as Episode[],
};
@@ -105,7 +114,7 @@ export default async function podcastRoutes(app: FastifyInstance) {
d.lightning_address ?? null, d.keysend_node ?? null, d.value_suggested ?? null,
d.resale_producer_share_pct, nowSecs(), id,
);
return getPodcast.get(id) as Podcast;
return serializePodcast(getPodcast.get(id) as Podcast);
});
app.delete('/api/podcasts/:id', { preHandler: app.requireAuth }, async (req, reply) => {
@@ -168,10 +177,10 @@ export default async function podcastRoutes(app: FastifyInstance) {
const d = { ...episode, ...parsed.data };
db.prepare(`
UPDATE episodes SET title=?, description=?, duration_secs=?, season=?, episode_no=?,
pub_date=?, price_sats=?
pub_date=?, price_sats=?, unlisted=?
WHERE id=?
`).run(d.title, d.description, d.duration_secs ?? null, d.season ?? null,
d.episode_no ?? null, d.pub_date, d.price_sats ?? null, eid);
d.episode_no ?? null, d.pub_date, d.price_sats ?? null, d.unlisted ? 1 : 0, eid);
return getEpisode.get(eid, id) as Episode;
});
+16 -1
View File
@@ -6,6 +6,8 @@ const updateSchema = z.object({
relays: z.array(z.string().regex(/^wss?:\/\//)).optional(),
public_url: z.string().url().optional(),
cashu_mint_url: z.string().url().optional(),
login_allowlist_enabled: z.boolean().optional(),
login_allowlist: z.array(z.string().regex(/^[0-9a-f]{64}$/i)).optional(),
});
export default async function settingsRoutes(app: FastifyInstance) {
@@ -33,11 +35,24 @@ export default async function settingsRoutes(app: FastifyInstance) {
}
const parsed = updateSchema.safeParse(req.body);
if (!parsed.success) return reply.code(400).send({ error: parsed.error.message });
const { blossom_url, relays, public_url, cashu_mint_url } = parsed.data;
const {
blossom_url,
relays,
public_url,
cashu_mint_url,
login_allowlist_enabled,
login_allowlist,
} = parsed.data;
if (blossom_url !== undefined) settings.set('blossom_url', blossom_url);
if (relays !== undefined) settings.set('relays', JSON.stringify(relays));
if (public_url !== undefined) settings.set('public_url', public_url);
if (cashu_mint_url !== undefined) settings.set('cashu_mint_url', cashu_mint_url);
if (login_allowlist_enabled !== undefined) {
settings.set('login_allowlist_enabled', login_allowlist_enabled ? 'true' : 'false');
}
if (login_allowlist !== undefined) {
settings.set('login_allowlist', JSON.stringify(login_allowlist.map((pk) => pk.toLowerCase())));
}
return settings.all();
});
}
+17
View File
@@ -7,6 +7,8 @@ export interface Settings {
public_url: string;
admin_pubkey: string | null;
cashu_mint_url: string;
login_allowlist_enabled: boolean;
login_allowlist: string[];
}
export class SettingsService {
@@ -32,6 +34,8 @@ export class SettingsService {
public_url: this.get('public_url') ?? this.config.PUBLIC_URL,
admin_pubkey: this.get('admin_pubkey'),
cashu_mint_url: this.get('cashu_mint_url') ?? this.config.CASHU_MINT_URL_DEFAULT,
login_allowlist_enabled: this.get('login_allowlist_enabled') === 'true',
login_allowlist: JSON.parse(this.get('login_allowlist') ?? '[]'),
};
}
@@ -64,4 +68,17 @@ export class SettingsService {
isAdmin(pubkey: string): boolean {
return this.get('admin_pubkey') === pubkey;
}
/**
* Whether a pubkey may log in. Disabled by default (everyone allowed). When enabled,
* the admin and no-admin-claimed-yet bootstrap case always pass, otherwise the pubkey
* must be in the allowlist.
*/
isLoginAllowed(pubkey: string): boolean {
if (this.get('login_allowlist_enabled') !== 'true') return true;
if (!this.get('admin_pubkey')) return true;
if (this.isAdmin(pubkey)) return true;
const list: string[] = JSON.parse(this.get('login_allowlist') ?? '[]');
return list.includes(pubkey);
}
}
+1
View File
@@ -41,6 +41,7 @@ export interface Episode {
price_sats: number | null;
pub_date: number;
created_at: number;
unlisted: number;
}
export interface Purchase {