Skip to content

Connect nixamp with OAuth 2.1, and put a watch party on it as a room - #217

Merged
ralyodio merged 1 commit into
masterfrom
feat/watch-party-nixamp-oauth21
Sep 12, 2026
Merged

ralyodio merged 1 commit into
masterfrom
feat/watch-party-nixamp-oauth21

Conversation

@ralyodio

Copy link
Copy Markdown
Contributor

A watch party on bittorrented.com becomes a room on nixamp.com, joinable from every nixamp surface -- the web app, a terminal, the desktop app, a television, an agent over MCP -- while the film stays here, on the site that has the torrent and the rights.

Pairs with profullstack/nixamp#127, which makes nixamp the OAuth 2.1 authorization server. This is the client half.

The direction, and why

nixamp keeps accounts, revocable tokens and public handles; we keep none of those. So nixamp is the AS, we are the client, and src/lib/nixamp/ is a third-party-OAuth integration in the shape src/lib/youtube/ already uses -- plain fetch, a per-module getNixampOAuthConfig(), env read inside a function so Next cannot inline a secret at build time.

The new part is PKCE, which nothing else in this repo does and which OAuth 2.1 requires of every client. The verifier and the CSRF state ride out together in one httpOnly cookie and never reach page JavaScript.

The thing most likely to bite whoever touches this next

nixamp rotates refresh tokens. Refreshing is a write, not a read: nixamp retires the token just used, and presenting a retired one withdraws the whole family. So in accounts.ts:

  • the new pair is stored before the access token is handed to any caller. If this process died between the exchange and the write, the row would hold a retired token and the next refresh would withdraw everything.
  • a refusal deletes the row rather than being retried. Retrying is what turns a recoverable state into a connection that has silently died, and the caller gets NixampConnectionLost so the UI can say "connect it again" instead of "something went wrong".

The bridge

src/lib/nixamp/rooms.ts puts the room under the host's own nixamp account, not a service credential. The room is a person's over there: it appears in their events and is theirs to end. A service account would make every party on nixamp belong to "bittorrented", which is nobody.

POST /api/watch-party now takes the signed-in user as the host when there is one. A party still needs no account -- an anonymous host gets a guest id exactly as before -- but putting one on nixamp has to be provably the host's doing, and guest_1757... proves nothing. The bridge endpoint checks twice: signed-in user, and host of the party as the store has it.

GET /api/watch-party/nixamp?code= is public, because a member handed a code needs the room link and that link is not the secret -- the room's own visibility on nixamp decides who may be in it.

Also here

  • Two tables following the bt_youtube_accounts shape: bt_nixamp_accounts and bt_watch_party_rooms, with RLS and the updated_at trigger.
  • A Connections tab in settings (/settings?tab=connections), the first management UI for any connected account in this repo.
  • An "Also on nixamp" panel in the party room. The host gets a bridge button and a Sync button, and the position goes over on its own every 15s -- somebody opening the room from a terminal an hour in should land an hour in, and asking the host to press a button for that would not happen.
  • /api/v1/nixamp added to PROFILE_EXEMPT_PATHS in src/proxy.ts. Without it a signed-in user with no profile cookie is bounced to /select-profile mid-consent and loses the code, the state and the verifier.
  • .env.example documents the four NIXAMP_* vars. In production none of them need setting: nixamp ships knowing bittorrented and its redirect URIs.

The handle is what is shown anywhere a person is named. On nixamp the account email is the OAuth linking key and is a credential; the handle is the name that is safe in a room full of strangers.

Testing

pnpm test: 2871 pass, 5 skipped, 0 fail (28 new). pnpm typecheck clean. pnpm lint has no warnings from any new file. pnpm build green, with all four new routes registering.

Known gaps

  • The watch party store is still the in-memory Map beside the route, so a bridged party survives a deploy on the nixamp side and not on ours. bt_watch_party_rooms is durable, which means the room link outlives the party -- worth persisting the party itself next, per the schema already sketched in plans/bittorrented-expansion-plan.md.
  • There is still no SSE or realtime on the watch party, so the chat panel is as static as it was. nixamp's room has working chat, which is a reason to lean on it.

🤖 Generated with Claude Code

https://claude.ai/code/session_01QuUkTrofSjQ15j79mRuy4f

A watch party on bittorrented.com becomes a room on nixamp.com, joinable from
every nixamp surface -- the web app, a terminal, the desktop app, a television
and an agent over MCP -- while the film stays here, on the site that has the
torrent and the rights.

nixamp is the authorization server and we are the client. It already keeps
accounts, revocable tokens and public handles; we keep none of those, so the
direction was not really a choice. The flow is OAuth 2.1, which means PKCE on
every request (src/lib/nixamp/oauth.ts is the first PKCE in this repo), a
redirect URI matched byte for byte, and refresh tokens that ROTATE.

That last one shapes src/lib/nixamp/accounts.ts more than anything else.
Refreshing is a write, not a read: nixamp retires the token just used, and
presenting a retired one withdraws the whole family. So the new pair is stored
BEFORE the access token is handed to a caller, and a refusal deletes the row
rather than being retried -- retrying is what turns a recoverable state into a
connection that has silently died.

The bridge (src/lib/nixamp/rooms.ts) puts the room under the HOST's nixamp
account, not a service credential: the room is a person's over there, appears
in their events, and is theirs to end. A service account would make every
party on nixamp belong to "bittorrented", which is nobody.

POST /api/watch-party now takes the signed-in user as the host rather than a
guest string when there is one. A party still needs no account -- an anonymous
host gets a guest id exactly as before -- but putting one on nixamp has to be
provably the host's doing, and `guest_1757...` proves nothing.

Also here: two tables (bt_nixamp_accounts, bt_watch_party_rooms) following the
bt_youtube_accounts shape; a Connections tab in settings with connect and
disconnect; an "Also on nixamp" panel in the party room that pushes the host's
position every 15s so somebody joining from a terminal an hour in lands an
hour in; and /api/v1/nixamp added to the proxy's profile-exempt paths, or a
signed-in user with no profile cookie gets bounced to /select-profile in the
middle of the consent round trip and loses the code, state and verifier.

The handle is what is shown anywhere a person is named. On nixamp the account
email is the OAuth linking key and is a credential; the handle is the name
that is safe in a room full of strangers.

2871 tests pass, typecheck clean, lint clean for the new files, build green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QuUkTrofSjQ15j79mRuy4f
@github-actions

Copy link
Copy Markdown

ThreatCrush Security Scan

99 finding(s)

HIGH/CRITICAL: 11 | MEDIUM: 29 | LOW: 59

Severity Rule Location
HIGH secret-private-key src/app/settings/seedbox-section.tsx:412
HIGH secret-generic-api-key docs/incidents/2026-05-okshanaby-supply-chain.md:18
HIGH tls-verification-disabled src/app/api/iptv-proxy/route.ts:38
HIGH tls-verification-disabled src/app/api/iptv/channels/route.ts:35
HIGH tls-verification-disabled src/app/api/iptv/playlists/[id]/route.ts:73
HIGH tls-verification-disabled src/app/api/iptv/playlists/route.ts:64
HIGH js-cors-origin-reflected src/app/api/public/shares/[slug]/checkout/route.ts:40
HIGH js-cors-origin-reflected src/app/api/public/vod/[slug]/checkout/route.ts:34
HIGH tls-verification-disabled src/lib/iptv/shares/upstream.ts:37
HIGH tls-verification-disabled workers/iptv-cache/epg-fetcher.ts:25
HIGH tls-verification-disabled workers/iptv-cache/playlist-fetcher.ts:62
MEDIUM secret-jwt .github/workflows/ci.yml:120
MEDIUM secret-jwt .github/workflows/ci.yml:121
MEDIUM secret-jwt .github/workflows/ci.yml:123
MEDIUM secret-jwt .github/workflows/ci.yml:162
MEDIUM secret-jwt .github/workflows/ci.yml:164
MEDIUM secret-jwt docs/tunein (2).py:9
MEDIUM secret-jwt docs/tunein.py:9
MEDIUM sh-remote-script-execution scripts/setup-server.sh:182
MEDIUM sh-remote-script-execution scripts/setup-server.sh:419
MEDIUM sh-remote-script-execution scripts/setup-server.sh:428
MEDIUM sh-unquoted-expansion-destructive scripts/setup-server.sh:1096
MEDIUM sh-unquoted-expansion-destructive scripts/setup-server.sh:1106
MEDIUM js-unescaped-html-sink src/app/api/player/route.ts:110
MEDIUM js-unescaped-html-sink src/app/api/player/route.ts:249
MEDIUM js-unescaped-html-sink src/app/blog/[slug]/page.tsx:40
MEDIUM js-unescaped-html-sink src/app/blog/[slug]/page.tsx:66
MEDIUM js-unescaped-html-sink src/app/email/email-content.tsx:566
MEDIUM js-open-redirect src/app/login/page.tsx:50
MEDIUM js-open-redirect src/app/pricing/page.tsx:162
MEDIUM js-open-redirect src/app/rent/[slug]/rent-client.tsx:170
MEDIUM js-unescaped-html-sink src/app/rss/rss-content.tsx:615
MEDIUM js-open-redirect src/app/vod/[slug]/vod-client.tsx:134
MEDIUM js-open-redirect src/app/watch/[slug]/watch-client.tsx:129
MEDIUM js-unescaped-html-sink src/app/youtube/youtube-content.tsx:546
MEDIUM js-open-redirect src/components/account/iptv-subscription-section.tsx:135
MEDIUM js-open-redirect src/components/account/iptv-subscription-section.tsx:167
MEDIUM js-unescaped-html-sink src/components/news/news-section.tsx:361
MEDIUM js-unescaped-html-sink src/components/news/news-section.tsx:734
MEDIUM redos-nested-quantifier src/lib/metadata-enrichment/metadata-enrichment.ts:317
LOW tls-verification-disabled docs/tunein (2).py:34
LOW tls-verification-disabled docs/tunein (2).py:37
LOW py-xpath-injection docs/tunein (2).py:37
LOW tls-verification-disabled docs/tunein (2).py:47
LOW py-xpath-injection docs/tunein (2).py:47
LOW tls-verification-disabled docs/tunein.py:34
LOW tls-verification-disabled docs/tunein.py:37
LOW py-xpath-injection docs/tunein.py:37
LOW tls-verification-disabled docs/tunein.py:47
LOW py-xpath-injection docs/tunein.py:47

…and 49 more. Full results in the Security tab.

Snippets are redacted; ThreatCrush never prints matched credential material.

@ralyodio
ralyodio marked this pull request as ready for review September 12, 2026 11:23
@ralyodio
ralyodio merged commit 29eb6ac into master Sep 12, 2026
9 checks passed
@ralyodio
ralyodio deleted the feat/watch-party-nixamp-oauth21 branch September 12, 2026 11:23
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