Phase-0: v2 adapter contract + grant-stream core + Jellyfin & Invidious adapters - #64
Draft
returnsvoidjanet wants to merge 44 commits into
Draft
Phase-0: v2 adapter contract + grant-stream core + Jellyfin & Invidious adapters#64returnsvoidjanet wants to merge 44 commits into
returnsvoidjanet wants to merge 44 commits into
Conversation
Nexus-owns-identity adapter contract v2 (separate from v1 contract.ts): - contract.ts: NexusAdapter interface (no UserCredential anywhere), 3-tier model, capabilities as single source of truth, declareAdapter<T>() with compile-time guard against derived flags + banned v1 keys. - conformance.ts: pure assertConformant(), HA-style per-rule modules A-G, MCP SKIP/FAIL asymmetry, conformanceExempt escape hatch, all-violations- in-one-run. - registry.ts: register() runs the gate, throws AdapterConformanceError on hard failures (all reported), derives + stores flags. - __tests__: conformant media-source/nexus-native/request stubs, a broken stub, an exempt stub, and 17 passing vitest specs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…rite, golden-vector) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ness Jellyfin media-source adapter via declareAdapter (registers conformant): single service cred + exact MediaBrowser header, getUserId (/Users/Me→/Users fallthrough), probeServiceCredential via /System/Info, ported mappers, and negotiatePlayback minting a grant → Nexus-origin /api/stream-proxy URL. Adapter-agnostic glue: POST /api/play/negotiate (registry lookup → negotiate), GET /api/stream-proxy/[...path] (thin path-preserving forward to the Rust proxy with Range/header passthrough), /api/subtitles/jellyfin WebVTT proxy, env config shim (v2-services.ts), and the back-compat grant-URL rewrite fix. Bare test-play/[backend]/[id] harness reusing hls/dash/progressive engines. Live integration test against the seeded Jellyfin: direct-play (h264) → progressive + grant URL + external English .srt surfaced + real 206 byte stream through the proxy; transcode (hevc under h264 profile) → hls + grant URL. All 8 live assertions pass; svelte-check 0 errors. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The stream grant is session-bound, but the user_id never reached the mint:
the registry-exposed jellyfin.negotiatePlayback dropped the 5th ctx arg, so
mint + /session both fell back to 'legacy' (self-consistent → /session passed)
while the seam stamped the real user at /stream → BadToken on the legit path,
and (worse) a copy-pasted URL only failed by luck.
Fixes, threading the Nexus user identity through to the AEAD implicit assertion:
- jellyfin adapter: pass ctx through the registry method to the inner negotiate
→ createStreamSession(userId) → mintStreamGrant(user_id)
- negotiate seam: require locals.user, pass { nexusUserId } as ctx
- reverse-proxy seam: require a session, stamp x-nexus-user
- Rust /session + /stream: verify the grant against the same user
- conformance Rule F: negotiatePlayback arity 4→5 — the trailing ctx is the
Nexus REQUEST context (which user the grant binds to), NOT a backend
UserCredential (the type contract + Rule E remain the real cred ban). Added
two specs locking ctx-allowed / 6th-param-rejected.
Proven E2E against a live Jellyfin + a real Chromium:
A own session → 206 (real H.264) B no session → 403 C bogus → 403
expired grant → 403 (live 206→403 + golden vector) wrong-user/gen → 403
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…harness verify-deps-before-run + an ignored-builds placeholder made `pnpm dev` exit 1; run vite directly and scope onlyBuiltDependencies to better-sqlite3 + esbuild. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Rebuilt from the v1 SID/HMAC adapter for the v2 contract: - no per-user backend account — anonymous media-source (serviceAuth.required =false), Nexus owns identity; subs/history become nexus-native, not here - local=true MANDATORY: googlevideo URLs are IP-locked to the instance, so every upstream is instance-proxied (also keeps the browser off Google) - playback rides the SAME PASETO grant + held-cred proxy spine as Jellyfin: resolve the playable /videoplayback URL server-side → createStreamSession → credential-free, session-bound /api/stream-proxy URL. Copy-paste / replay / expiry defenses inherited unchanged. - ported the hard-won DASH-level + muxed-format-pick math from v1 Proven E2E against the lab instance (10.10.10.15:3010) + a real Chromium: real YouTube (dQw4w9WgXcQ, 640x360) decodes + currentTime advances through Nexus, seek works, copy-pasted grant URL (no session) → 403. Known gaps (honest, documented in the adapter header): - DASH-adaptive (>360p): the companion-backed instance 302s its DASH manifest to an internal inv-companion host the proxy can't reach from outside the Docker network — needs in-network proxy reach or instance DASH proxying. - captions: this instance's /api/v1/captions is YouTube-bot-blocked (returns a gzipped Google "Sorry" page); also exposes a content-encoding passthrough gap in the proxy (Rust→seam drops content-encoding) to fix before captions ship. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A gzipping upstream (e.g. Invidious captions / any text/* backend) produced garbage downstream: reqwest is built without the gzip feature so it forwarded the compressed body verbatim, AND `cached_or_stream` forwarded the browser's `accept-encoding: gzip` to the upstream — but the SvelteKit seam's undici fetch strips `content-encoding` off the headers while handing us the still-compressed `.body` stream, so the browser received gzip bytes labelled `text/vtt` and parsed 0 cues. Fix at the right layer — the proxy negotiates encoding itself: - HTTP_CLIENT sends `Accept-Encoding: identity` by default - stop forwarding the client's `accept-encoding` upstream - (seam also passes `content-encoding` through for transparent correctness) Media containers are already compressed, so identity costs nothing on the hot streaming path. Proven: a compliant gzip-VTT upstream now arrives DECODED through the full seam→proxy chain (114B WebVTT, no gzip magic). No regression: Jellyfin core 6/6 (subs 3 cues, HEVC transcode 720p), Invidious 4/5 (the lone caption miss is this lab instance's YouTube-side bot block, not the proxy). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A 4K concurrency load test (40 simultaneous negotiates) surfaced a fail-OPEN
leak: when createStreamSession's /session handoff failed, both adapters fell
back to the RAW backend URL (Jellyfin /Videos/{id}/stream origin+PlaySessionId,
Invidious /videoplayback) — handing the browser an un-proxied URL that bypasses
the grant entirely and exposes the backend origin/credential.
- jellyfin + invidious negotiate now THROW when the handoff returns null
(fail closed: no grant proxy URL ⇒ no playback, never the raw URL)
- /session handoff timeout 5s → 15s: the leak's trigger was the 5s timeout
tripping under burst (the POSTs queue behind PlaybackInfo on the single
dev-server thread; the timeout counts queue time)
Re-tested after the fix: 20/40/60 concurrent 4K direct-play streams all
sustained (0 failures), ~4.1 Gbps aggregate ceiling, per-stream still 6x a 4K
playback bitrate at N=60 (~200 playback-rate 4K streams of headroom). Failed
handoffs now surface as clean 500s, never a leaked URL.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…stop
Decision 1 of the 3 open calls (researched: matches Jellyfin/Plex/Emby).
Closes the "close tab → transcode keeps running" gap.
- playback-sessions.ts: in-memory session registry + reaper. negotiate
registers a session (capturing the adapter's close handle, which the wire
response drops); player keepalives every 10s; a sweep reaps any session
silent for 30s (3 missed pings) and calls the adapter stop. Ownership-checked
(only the owning user can heartbeat/stop their session).
- /api/play/heartbeat + /api/play/stop endpoints (cookie-authed). negotiate
now returns playbackSessionId.
- test-play harness: 10s keepalive + navigator.sendBeacon('/stop') on pagehide
+ stop-old-on-renegotiate.
- jellyfin session.close: the key fix — Sessions/Playing/Stopped is only a
session-state REPORT; Jellyfin keeps ffmpeg alive while segments are pulled,
so on its own it does NOT stop a transcode. Now DELETE /Videos/ActiveEncodings
(deviceId+playSessionId) force-kills ffmpeg first (verified 204), then reports
Stopped.
Proven E2E (real Chromium + a 12-min asset so ffmpeg genuinely stays alive):
close tab → transcode dies in ~2s, vs ~32s for Jellyfin's own idle timeout with
no stop (16x faster). Reaper backstop sweeps on 30s silence when the beacon is
lost. (Short clips transcode faster-than-realtime and masked this — hence the
long-asset test.)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…sion 2) Decision 2 of the 3 open calls. The research's "60-120s token" advice assumes the URL token is the ONLY auth — but Nexus also has the session COOKIE authenticating every request at the seam (that's what actually defeats copy-paste). So we're already the signed-cookie pattern, and a 90s grant would BREAK progressive playback (one long GET + range requests over the whole movie would 403 after 90s). Decision: keep a generous grant TTL (no mid-watch breakage) and add UNIVERSAL reactive recovery instead. - test-play harness: on a <video> error (a range/segment fetch that 403s on an expired grant), re-negotiate a fresh grant and resume at the same timestamp, auto-resuming playback. Bounded to 5 attempts to avoid a hot loop. (The real HLS/DASH engines would call the same recover() from their fatal-error hooks.) Proven E2E (real Chromium, injected one-shot 403 mid-watch on the 4K asset): 403 → re-negotiate → resume at the sought position (45s) and keep playing (currentTime advanced 45→51, no permanent error, 2160p intact). Closes the "expire → refresh recovers" gap from the morning report. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Decision 3, now enabled (Parker greenlit the .15 config change): dropped
public_url from the instance's invidious_companion config so it self-proxies
the companion DASH (the manifest no longer 302s to an unreachable internal
host). With that reachable, Nexus now streams adaptive DASH end to end:
- Rust proxy: new `/v/{id}/dash` route fetches the instance MPD (following the
302 via the redirect-capable client) and rewrites every <BaseURL> so each
representation's byte-range segments route back through the grant proxy
(/v/{id}/seg/{hex}?grant=...), reusing the existing seg machinery + Range
passthrough. Analog of the HLS m3u8 rewrite. +5 Rust tests.
- boot/proxy.ts: wire the real instance via NEXUS_INVIDIOUS_URL as both the /v
fallback base AND a held cred for the `invidious` backend, so the DASH/seg
grant routes resolve the instance (was defaulting to localhost:3000).
- invidious adapter: prefer DASH when adaptiveFormats exist — mint a grant for
/v/{id}/dash, return engine:'dash' with levels up to source res; progressive
muxed stays the fallback for videos with no adaptiveFormats.
Proven E2E (real Chromium, dQw4w9WgXcQ): engine=dash, ABR reached 2160p through
Nexus (vs the 360p progressive cap), playing + advancing, browser never touches
the instance/googlevideo; copy-pasted DASH manifest (no session) → 403.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…idth pick Approach A for "really good adaptive quality" (Parker), grounded in research: an on-the-fly rendition ladder is the wrong move (even Jellyfin's multi-variant master restarts the same transcoder on a switch; parallel renditions = N× ffmpeg on a CPU-only box; Plex/Emby don't do it either). Smoothness comes from avoiding transcode and picking the right single bitrate UP FRONT. - PlaybackPlan.measuredBandwidthBps: a SOFT cap (bits/sec from the client's real link). Unlike targetHeight/maxBitrate (explicit pick → forces transcode), it only sets MaxStreamingBitrate and keeps direct-play enabled — so Jellyfin plays the source DIRECT when it fits the link and transcodes down only when it doesn't. 85% headroom for overhead/jitter. - /api/play/probe: streams an incompressible payload so the client measures real download throughput. - test-play harness: probe the link before the first negotiate and fold the result into the plan, so the very first stream is already the right quality. Proven E2E (real Chromium): fast link (373 Mbps) → direct-play 2160p; throttled to 5 Mbps (CDP) → measures 4 Mbps → transcodes down to a smooth 720p that plays, instead of choking on 4K-over-5Mbps. No-hint default still direct-plays. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…t-start tuning (PetalNet#3) Approach-A refinements 3a + 3b, research-backed. 3b (proven-applied): - hls.js: cap backBufferLength to 90s (default Infinity = memory leak on long sessions, Mux QoE), maxBufferLength 30 / maxMaxBufferLength 600, seed abrEwmaDefaultEstimate at 1Mbps (was 50M → blind over-shoot), keep startLevel -1 (start-low-ramp-up). lowLatencyMode off (VOD). - dash.js: abrDynamic + low initialBitrate (800kbps, start-low) + fastSwitch + buffer settings (was starting at 50Mbps = top-quality stall on a thin link). - native progressive: <video preload="auto"> (assets are +faststart). 3a (built per research; see proof caveat below): - Buffer-veto adaptation watchdog in the harness: forward-buffer is the trusted signal (throughput is noisy after a transcode warm-up). STEP DOWN when buffer <4s sustained 5s / <1.5s critical / 2 stalls in 10s; STEP UP only after 20s+ buffer held 30s. Fast-down/slow-up with a 30s debounce + 60s post-down step-up suppression so it never flaps. The new target comes from a FRESH throughput read (engine EWMA for hls/dash, a small re-probe for progressive) — not a blind step off a stale value (which converges far too slowly on a sudden drop). Re-negotiates + resumes at position via the existing recover() path. PROOF STATUS (honest): 3b is applied + the up-front smart-bitrate it builds on is proven (fast link→4K direct-play, throttled→transcode-down). The 3a mid-stream TRIGGER is NOT yet demonstrated E2E: the loopback test rig can't induce sustained mid-stream starvation — progressive pre-fetches the whole file, and CDP throttle bursts so the probe over-reads. Needs a real throttled client / netem-shaped link to demonstrate the downshift firing. Logic follows the researched thresholds. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… proven E2E Parameterizing probeBandwidth(bytes=…) collided with the inner byte-accumulator `let bytes` → a TDZ ReferenceError on the fetch URL, swallowed by the catch, so the probe always returned null. That left effectiveBandwidthBps null, which the adapt watchdog's guard treated as "not ready" and skipped every tick — silently disabling the entire adaptive feature. Renamed the accumulator to `received`. Now PROVEN end-to-end against a userspace rate-limited link (steady token bucket, no CDP burst, throttles the progressive download too so it can't pre-fetch): start 32Mbps → direct-play 4K; drop the link to 1.2Mbps mid-stream → forward buffer drains 6s→0.3s → the buffer-veto watchdog fires, re-measures the degraded link (1.2Mbps), steps the target to 0.8Mbps → re-negotiates direct-play→transcode → resumes at position at a bitrate that fits → ONE switch, no flapping. Closes the 3a proof gap from the prior commit. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ist + resume) The account model is largely already built (users+sessions+scrypt, play_sessions + user_watchlist + continue-watching.ts keyed by user×service×media, matching the locked Nexus-as-identity design). The one v2 gap: the new grant-based playback flow wasn't reporting progress into that store. Wired it: - /api/play/progress: generic (backend-agnostic) progress report → upsertPlaySession (the canonical play_sessions table continue-watching reads). GET returns the resume point. Progress stays PURELY in Nexus per the locked model — no per-user write-back to the backend (the single service cred can't attribute it). - test-play harness: report position every 10s + a final keepalive/sendBeacon on tab close, and resume from the saved point on load. Proven E2E (real Chromium): watch to 14s → close → progress persisted (pos 13.97, 0.23) → reload → resumes at 14.0s. Watchlist API already exists (api/user/watchlist) — its v2 UI hookup is front-end work. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adversarial review found any logged-in user could turn the stream proxy into a
credentialed open proxy into the lab LAN + loopback + cloud metadata.
- SEAM (src/routes/api/stream-proxy/[...path]): was a path-preserving forward of
ANY path to the Rust binary, exposing its legacy UNGATED routes — /proxy?url=
(arbitrary-URL SSRF, body returned), /stream/{id} (grant bypass), /stats
(info leak). Now a HARD ALLOWLIST: only `stream` and `v/…`, and EVERY request
must carry a ?grant=. Everything else → 403.
- seg/{hex} (invidious.rs): hex-decoded an attacker path and join_instance()
passed absolute URLs verbatim → SSRF + held-cred exfil to any host. Now
constrained via instance_relative_path() (companion/videoplayback only) and
re-anchored to the configured instance.
- resolve_relative (session.rs): same absolute-URL passthrough on the inline
/stream HLS suffix (worse — injects the Jellyfin ApiKey). Now re-anchors any
absolute URL's path to the verified upstream origin. +test.
Verified: legit Jellyfin /stream=206 and Invidious /v/dash=200 still work; the
SSRF routes (/proxy //stats /stream/{id}) all 403; grant-less requests 403. Rust
40/40. The grant/PASETO core was sound — these were legacy routes + missing
input constraints.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ess clamp Three HIGH findings from the adversarial review: - F1 conformance self-exempt: conformanceExempt could downgrade ANY rule, including the security ones — an adapter could opt out of Rule E (banned UserCredential surface) / Rule F (arity backstop) and ship the exact thing the gate exists to block. A/E/F are now NON-WAIVABLE (only C/D/G coherence may be exempted). +test proving a self-exempt still hard-fails. - F2 unbounded sessions/transcodes (DoS): registerSession now caps at 8 live sessions per user, stopping the oldest on overflow (each session pins a real backend transcode + cred). - F3 progress input: positionSeconds is now clamped to [0, duration|7d] and finite-checked (was unbounded → tick overflow + garbage resume seek). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ity fallback Two defense-in-depth follow-ups from the adversarial review: - Seam↔proxy shared secret: the Rust proxy trusted x-nexus-user from anything that reached loopback. Now a per-boot secret (NEXUS_PROXY_AUTH, generated by the supervisor, injected into the child, attached by the seam + the /session POST as x-nexus-proxy-auth) gates every request except /healthz. A process that gains loopback access can no longer forge identity. - Removed the "legacy" default user on the /stream and /v handlers — a missing x-nexus-user is now a hard 403 (the default had nulled cross-user binding on the v1 paths). Verified: direct :3939 request without the secret → 403; /healthz still 200; through the seam A=206/B=403/C=403, Invidious DASH=200. Rust 40/40. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… the fix) The new-code adversarial pass found my own allowlist had a CRITICAL bypass: it checked the DECODED path but built the upstream as a string fetch() re-normalizes, so `v%2f..%2fproxy?grant=x&url=…` (or a literal `../proxy`) passed the seg0=="v" check then collapsed to `/proxy` — reaching the ungated open-proxy SSRF route, with the seam's own auth secret SIGNING the smuggled request (so the proxy-auth gate couldn't help). This reopened the exact hole the prior commit closed. Fix: reject any path containing `%` or a `.`/`..`/empty segment BEFORE forwarding, so the path that's allowlist-checked == the path forwarded == the path the Rust proxy routes on (nothing left for fetch to normalize). Verified: `v%2f..%2fproxy` and `v/../proxy` → 403; legit /stream=206, /v/dash=200. (Belt-and-suspenders — a private-IP guard on the Rust /proxy route — tracked; the route is now unreachable since only the secret-holding seam reaches the proxy.) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…penders)
The dormant invidious::handle routes (/proxy?url=, /stats, /stream/{id}) were the
open-proxy SSRF + info-leak surface. They're unused by v2 (/session, /stream, /v)
and unreachable (only the secret-holding seam reaches the proxy, allowlisting just
/stream and /v) — so the dispatch now refuses anything unrecognized with a 404,
removing the surface entirely rather than leaving it dormant-and-reawakenable.
Verified: legit /stream=206, /v/dash=200; direct :3939 /proxy without secret=403.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adopt Better Auth as the Nexus identity/session/RBAC layer (Eli: don't roll your own auth; research verdict: adopt-now). Better Auth defaults to scrypt (same crypto family — not a philosophy change), gives RBAC (admin plugin) + OIDC for Authentik (generic-oauth) + 2FA + session revocation (the last closes the gen-revocation gap from the security review). This commit: install better-auth 1.6.20 + the config foundation (src/lib/server/auth/better-auth.ts) — drizzleAdapter(sqlite, usePlural) + emailAndPassword with a legacy-scrypt verify shim (existing users keep their passwords, no forced reset) + username + admin plugins + Authentik OIDC gated behind env. Import paths + plugin names verified against better-auth 1.6.20. NOT yet wired into hooks (deliberately — it's the critical auth path). Next increment: CLI-generate the Drizzle schema (account/verification tables) → migrate → backfill account rows from existing users.passwordHash (tested against a DB copy) → swap hooks.server.ts to the BA handler. Done as a careful, tested cutover so nobody gets locked out. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…sion layer Cut the account model over to Better Auth (coexisting with the legacy scrypt+cookie so no one is locked out during transition): - hooks: populate locals.user from a BA session (auth.api.getSession → getUserById) when there's no legacy cookie; legacy + BA sessions coexist. - login/register/logout route through BA (signInUsername / signUpEmail / signOut); the Jellyfin/Plex service-auth path stays legacy (external creds). - BA handler mounted at /api/auth/[...all]; dual password verify (BA crypto for new hashes, legacy scrypt shim for migrated ones) keeps every existing password working with no forced reset. - users-table rebuild migration (idempotent, guarded): created_at TEXT→int ms so the BA adapter can write Dates; display_name/password_hash made nullable for OIDC/credential users; adds BA columns + auth_sessions/accounts/ verifications tables. FK-checked, single-flight, atomic. Adversarial review (3 reviewers) hardening: - block public /api/auth/sign-up* + /api/auth/admin* at hooks (registration must flow through the gated /register action; admin endpoints unused). - drop the BA admin() plugin (no parallel role/ban/impersonate surface; RBAC stays the isAdmin column for now). - fail-closed BETTER_AUTH_SECRET validation at boot regardless of NODE_ENV. - baseURL/trustedOrigins for Secure-cookie + CSRF-origin derivation. - register patches display_name + approval status by returned id (BA lowercases usernames → a raw re-query missed, silently bypassing approval). - logout revokes ALL of the user's sessions in both tables + clears both cookies. - migration: NULLIF guards epoch-0 on bad dates; email unique index moved out of the txn so a dup can't roll back the whole conversion. Verified E2E (real HTTP + headless Chromium): existing-user login, new-user register→login, wrong-pw reject, logout, app-wide BA session, sign-up/admin endpoints 404, live-DB migration applied with FK integrity intact. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
svelte-check was red before the auth work (10 errors from the stream-proxy, adaptive-quality, and ctx-threading phases). Green it so the branch is shippable: - stream-proxy: type the spawn options as SpawnOptions so the (command, options) overload is selected (an untyped literal with `stdio` was misread as `args`, collapsing the return type to never); coerce the proxy-auth secret to undefined. - boot/proxy: annotate heldCreds as HeldCredTable (the empty-object branch). - invidious: add optional `height` to InvFormat (used for sourceHeight). - dash-engine: cast updateSettings (dash.js types lag the runtime ABRStrategy/ stableBufferTime settings; runtime config unchanged). - jellyfin integration test: pass the 5th ctx arg negotiatePlayback now requires. Verified: svelte-check 0 errors; app + Rust stream-proxy boot clean; login works. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Builds the Plex media-source adapter on the v2 contract (single service credential + PASETO grant + Rust proxy seam; the browser never sees the Plex origin or X-Plex-Token). Ports the certified mappers from the v1 adapter and drops the entire v1 identity surface (Rule E). - src/lib/adapters/v2/plex.ts (new): library/item/search/recentlyAdded/sessions + negotiatePlayback mirroring jellyfin.ts (direct-play-first, measured- bandwidth SOFT cap, FAIL CLOSED on proxy handoff, session.close stops the Plex transcode via /transcode/universal/stop). Calls Plex's transcode /decision endpoint. - Client-capability gate (the real fix live testing caught): Plex's "Chrome" profile is too permissive (green-lights HEVC/AC3 direct-play), so we gate on what THIS client can actually decode — video codec, audio codec, AND container — forcing transcode otherwise. Prevents silent direct-play of undecodable media with no fallback. - stream-proxy/src/handlers/hls.rs: strip X-Plex-Token from rewritten manifest segment URLs (credential-leak fix) + test. - src/lib/server/v2-services.ts: resolveServiceConfig falls back to the DB-backed services table (deterministic by createdAt) so DB-registered backends (Plex) resolve — a step off the env shim toward DB-backed configs. - Defensive guard: reject non-relative Plex part keys (pre-proxy SSRF). Verified E2E against a live test PMS on .14 (Big Buck Bunny h264 + an HEVC/AC3 fixture): direct-play h264 plays through the proxy; HEVC→h264 and AC3→AAC transcode (confirmed real Plex TranscodeSession, videoDecision=transcode) play through the proxy; capability gates correct across video/audio/container; zero X-Plex-Token or :32400 origin leaks in browser requests; typecheck 0 errors; conformance passes. Follow-up (flagged, not in this commit): decommission the live v1 Plex login surface (v1 registry still exposes authenticateUser → per-user token storage), part of the broader v1→v2 migration. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Spec PetalNet#2 part 2 (Parker chose native over Overseerr). Users request movies/TV, an admin approves, approved requests are added to Radarr/Sonarr — no Overseerr needed. Coexists with the existing Overseerr proxy path (used when an Overseerr service is enabled). - media_requests table (Drizzle + idempotent initDb CREATE): per-user requests with status lifecycle (pending→processing→available / declined), arr linkage, unique (userId,tmdbId,mediaType). - radarr.ts / sonarr.ts: lookup + requestMedia (standalone fns, not the generic ServiceAdapter signature). Sonarr resolves tmdb→tvdb via SkyHook and gates languageProfileId to v3 only (omitted on v4). - media-requests.ts: create/dedupe/approve/decline/markAvailable + reactivate. - routes: generalized POST/GET/PATCH /api/requests (native + Overseerr), new GET /api/discover/search (discovery via the arr's own /lookup — no TMDB key), new arr download webhook. Adversarial review (one reviewer) — fixes applied: - webhook auth: replaced the guessable Math.random service-id "token" with a per-service HMAC token (verify constant-time; admin gets the URL via an admin-only GET). 403 without/with wrong token. - webhook cross-service spoof: a request bound to arr service X can no longer be completed by a webhook from service Y on a bare tmdb/tvdb match. - approve guards on status==='pending' (no double-approve/replay); a declined request can be re-requested (reactivates the row) instead of being permanently blocked by the unique index. - concurrent-insert race → returns duplicate (not 500); seasons[] sanitized to ints; defensive seasons JSON.parse. Verified E2E against live test Radarr + Sonarr on .14: discover → request → approve adds the real movie/series (Radarr monitored; Sonarr SkyHook tmdb→tvdb resolved) → token-gated webhook flips it to available + notifies; re-request-after-decline works; typecheck 0 errors. Known v1 limitation: a TV request flips to available on the first imported episode (per-season completion tracking is a v2 item). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… env→DB configs Closes the three backend loose ends flagged in review (clear-cut, no design input): - change-password: route PUT /api/user/password through Better Auth's changePassword so the new password lands in the `accounts` row BA login verifies against. The old code wrote the legacy users.passwordHash (which BA login ignores), so a password change silently never took effect for migrated users. Verified E2E: change → new pw logs in, old pw rejected, wrong-current → 403. - v1 Plex login surface: removed `authenticateUser` from the v1 Plex adapter. Every login/register/invite/account-link path gates on that method existing, so this stops Nexus from storing per-user Plex tokens (which the single-service-credential model forbids) while keeping the admin PIN/token setup helpers. Confirmed the login page no longer offers "Sign in with Plex". - service configs off the env shim: boot seeds jellyfin/invidious into the `services` table from env if absent (idempotent), and resolveServiceConfig is now DB-first (env only a fallback) so admin UI edits take effect. Verified jellyfin/invidious seed + resolve from the DB. typecheck 0 errors. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Locks in the new backend behavior (especially the adversarial-review security fixes) with automated tests so the next change can't silently regress login or requests: - media-requests.test.ts (24): webhook HMAC token (accept/reject null/wrong/ truncated, constant-time), the cross-service-spoof scoping fix, dedupe + reactivate-after-decline, approve replay-guard (+ real arr call via mocks). - legacy-scrypt.test.ts (8): the dual-verify shim round-trips + fails closed on empty/short/malformed stored hashes. - plex-gate.test.ts (17): the client-capability gate across video/audio/ container, unknown-codec defer, forcing override. - plex.ts: extracted the inline gate into a pure exported clientMustTranscode() (behavior byte-identical) so it's unit-testable; negotiatePlayback calls it. 61 tests pass across these files; full targeted suite green; typecheck 0. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The genericOAuth provider sent an empty scope, so the IdP returned no identity. Request openid/email/profile explicitly. Authentik provider set up out-of-band (client + dev/prod callback URIs + standard scope mappings); creds live in .env. Verified: BA produces a correct Authentik authorize redirect (right client_id, scopes, callback) and the discovery endpoint resolves. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- mapProfileToUser: derive users.username (NOT NULL) from Authentik's preferred_username (→ email local-part → sub). Without it BA's OIDC user insert failed "NOT NULL constraint failed: users.username". - accountLinking: link an Authentik login to an existing local account with the same verified email (trusted IdP) instead of creating a duplicate. Verified E2E in a real browser: Nexus → Authentik (janet) → callback → Better Auth session, landing logged-in on the Nexus home. The login linked to the existing janet (one user, both `credential` + `authentik` accounts on the same id, no duplicate); password login still maps to the same account. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Better Auth reads the credential password from accounts, not users. A DB migrated from the pre-BA schema has users with password_hash but no account row, so they cannot log in. Backfill copies each legacy salt:hash into a credential accounts row on boot (idempotent, skips existing/OIDC users). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Canonical paper+ink design-system tokens (per homelab DESIGN.md) and a /dev/nexus-home preview porting the Nexus Home mock onto them. Geist fonts added; /dev allowlisted + rendered chrome-less so the preview stands alone. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
pnpm 11 makes ignored build scripts a hard error; the committed pnpm-workspace had placeholder allowBuilds values (set this to true or false) so the native better-sqlite3 binding never built and the image build failed. Set allowBuilds true and pin the Dockerfile off floating pnpm@latest (which silently changed behavior) to 11.1.2, matching local + the v9.0 lockfile. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
vite build imports server modules to bundle them, executing top-level code: better-auth.ts threw on a missing BETTER_AUTH_SECRET and boot() validated the crypto secret + spawned the proxy — all at module load, none valid at build time. Guard both with $app/environment building so they only run on a real server start; auth/secret checks still fail-closed at runtime. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Per Parker: the deployable app uses ONLY new code, no legacy. Deleted ~489 old files (all old routes, components, v1 adapters, the homepage-cache / recommendations / dashboard aggregation). Re-implemented the home data loader on the v2 adapters (registryV2 + resolveServiceConfig → getRecentlyAdded / getLibrary); home cards navigate to the bare /test-play streaming harness. Kept: v2 adapters, p0 backend (auth/db/boot/api), the paper+ink home, the test-play page, the auth flow. pnpm build green, svelte-check 0 errors. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…heck needs it) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Authentik outpost authenticates every request; hooks provision/find the user from X-authentik-username/groups (guarded by NEXUS_TRUST_PROXY). Deleted all app-owned auth screens (login/welcome/register/pending-approval/reset); resolveRedirect neutered to a no-op. No old auth UI in the deploy. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Eli QA: (1) posters were 404 — adapter emitted service=<id> but the proxy
resolves by backend type; emit config.type. (2) rail collapse janked from
{#if railOpen} popping labels — always render, fade via .rail.closed. (3) the
topbar avatar did nothing — now an Authentik sign-out link.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Eli QA round 2: rail now scrolls (overflow-y:auto, was hidden) and keeps a constant item height in both states (dropped the closed-state 46px) — collapse just drops labels, gmail-style. Albums/series/audio are shown but no longer launch the streaming test (only movie/episode/video play; audio needs the later mini-player, series need episode nav) — fixes the PlaybackInfo 500 on a MusicAlbum. Also cleaned the resolveRedirect no-op (deleted dead body) and the passthrough casts (0 svelte-check errors). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Removed the closed-state .rail-item centering (justify-content:center/padding:0), so icons keep their 14px left offset in both states; only the label collapses and fades. Eli QA. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- /api/media/art: fetch a cover from the iTunes Search API (no key) by metadata for music/albums Jellyfin has no art for; cached, server-side fetch. Adapter points artless album/music items at it. (Eli: auto-fetch missing thumbnails.) - Scrollbars styled thin/quiet/theme-aware app-wide (Eli: default bars hideous). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…to artist) For a MusicAlbum without its own cover, posterItemId resolves to the artist (ParentId), so the fallback never fired. Now: artless album/music -> iTunes fallback first; video poster logic untouched. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Defensive: if any poster ever 404s, hide the img so the surface tile shows instead of a broken-image icon. (Verified the Jellyfin posters actually load 200 — the earlier low headless count was lazy-loading, not breakage.) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…lections, post-its Snapshot of live rebuild/phase-0 work that was deployed (docker build = working tree) but had sat uncommitted ~8h. svelte-check 0/0, verified live on .15. - "everything is fake" cleanup: delete NexusPlayer + 6 child menus (no route used them; real player is /test-play), the account-linking trio (POSTed to a 404 endpoint), and client/unified-search.ts; strip fake home chrome. - Real search: /api/search (registryV2.searchable, parallel) + home search bar with client-side nucleo-matcher-wasm ranking (needs CSP wasm-unsafe-eval + vite-plugin-wasm + esnext), Cmd-K / "/" focus, keyboard nav. - Collections: getChildren on the adapter contract + Jellyfin impl; /collection/[backend]/[id] view; series/album cards route there. Per-type uncropped card art. - Post-it annotations: annotations table + service + /api/annotations(+/[id]) + Stickies.svelte behind an Annotate FAB (off by default so the app stays interactive). - Adapters: invidious DASH/trending + image-proxy-by-type fixes; negotiate partial-caps coalesce. - db: playlists/playlist_items + annotations tables. Docs: README/ROADMAP to phase-0 reality. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
New /library/[type] route (movies/shows/music/videos via Jellyfin+Invidious; books/games honest empty state). Wears the approved paper/ink "civic stationery / card-catalog" design: editorial masthead, featured hero (real backdrop), Recently Filed shelf, The Catalog grid with mono call-numbers + ledger rules + staggered reveal, paper grain. Mobile-responsive (rail→drawer, type-pill strip, bottom bar, 2-col grid). Loads real data via registryV2/getLibrary(type)/getRecentlyAdded (mirrors the home), reuses the home's play/collection link conventions, graceful art fallback. svelte-check 0/0. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Phase-0: v2 adapter contract + grant-stream core + Jellyfin & Invidious adapters
Overnight build. Goal was to set up and prove the adapters end-to-end (real instances + a real browser literally playing/recovering), not to build a front end. No facade — everything below was driven against a live backend and a headless Chromium; the honest gaps are called out.
What's here
declareAdapter, tiers, derived flags, per-rule conformance modules; CI-blocking + throws at boot on a non-conformant adapter)./api/stream-proxy/...URL. CDN-signed-URL shape + identity binding (the copy-paste defense). Node mints (paseto-ts), Rust verifies (pasetors); cross-language golden vector in CI.local=truemandatory (googlevideo URLs are IP-locked to the instance), same grant/proxy spine.Proven end-to-end (live instance + real Chromium)
currentTimeadvances; seek; HEVC → real transcode (mode=transcode, HLS, 720p, advancing); 3 subtitle cues fetched+parsed through the proxy.Honest gaps (not done / not provable tonight)
session.close()on browser disconnect (negotiate is stateless). Needs a keepalive-ping + reaper (or reliance on the backend's own inactivity reaper).inv-companionhost the proxy can't reach from outside the Docker network. Progressive (muxed itag) is what's proven./api/v1/captionsis YouTube-bot-blocked (gzipped Google "Sorry" page); also surfaced a content-encoding passthrough gap (Rust→seam dropscontent-encoding) to fix before captions ship.Do not merge yet — for review. Front end is intentionally a throwaway test-harness only.
🤖 Generated with Claude Code