Skip to content

Phase-0: v2 adapter contract + grant-stream core + Jellyfin & Invidious adapters - #64

Draft
returnsvoidjanet wants to merge 44 commits into
PetalNet:mainfrom
returnsvoidjanet:rebuild/phase-0
Draft

Phase-0: v2 adapter contract + grant-stream core + Jellyfin & Invidious adapters#64
returnsvoidjanet wants to merge 44 commits into
PetalNet:mainfrom
returnsvoidjanet:rebuild/phase-0

Conversation

@returnsvoidjanet

Copy link
Copy Markdown

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

  • v2 adapter contract + conformance gate (declareAdapter, tiers, derived flags, per-rule conformance modules; CI-blocking + throws at boot on a non-conformant adapter).
  • Grant-only stream core — PASETO v4.local sealed, session-bound, short-lived grant. The Rust byte-proxy holds the per-backend service credential server-side; the browser only ever sees a credential-free /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.
  • Jellyfin v2 adapter — direct-play + real transcode (HLS), device profile, side-loaded WebVTT subs, session lifecycle.
  • Invidious v2 adapter — Nexus-identity (anonymous media-source, no per-user account), local=true mandatory (googlevideo URLs are IP-locked to the instance), same grant/proxy spine.

Proven end-to-end (live instance + real Chromium)

  • Jellyfin: H.264 direct-play decodes + currentTime advances; seek; HEVC → real transcode (mode=transcode, HLS, 720p, advancing); 3 subtitle cues fetched+parsed through the proxy.
  • Copy-paste / identity gauntlet: own session → 206, no session → 403, bogus session → 403; expired grant → 403 (live 206→403 and golden vector); wrong-user / wrong-gen / tampered → 403.
  • Kill proxy mid-stream → recovers (supervisor respawns; playback resumes after seeking into an unbuffered region).
  • Invidious: real YouTube (640×360) decodes + advances through Nexus; seek; copy-pasted URL (no session) → 403.
  • Tests: conformance 22/22, Rust 34/34 (incl. golden-vector tamper/expiry/wrong-user/wrong-gen).

Honest gaps (not done / not provable tonight)

  • Close-tab transcode reaping: not independently verified — the 20s test assets transcode too fast to hold an active session, and nothing invokes the adapter's session.close() on browser disconnect (negotiate is stateless). Needs a keepalive-ping + reaper (or reliance on the backend's own inactivity reaper).
  • Auto grant-refresh on mid-stream expiry: enforcement is proven; the player surfaces an error rather than auto-re-negotiating. Wire proactive grant-sliding before relying on short exp in prod.
  • Invidious DASH-adaptive (>360p): the companion-backed lab instance 302s its DASH manifest to an internal inv-companion host the proxy can't reach from outside the Docker network. Progressive (muxed itag) is what's proven.
  • Invidious captions: this instance's /api/v1/captions is YouTube-bot-blocked (gzipped Google "Sorry" page); also surfaced a content-encoding passthrough gap (Rust→seam drops content-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

returnsvoidjanet and others added 30 commits June 20, 2026 22:45
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>
returnsvoidjanet and others added 14 commits June 22, 2026 10:01
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant