From c2f4213a6cf2f6beb3dae0083088e9679693212c Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 11 Sep 2026 21:47:21 +0000 Subject: [PATCH] Sign in with nixamp, and put a watch party on it as a room 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) Claude-Session: https://claude.ai/code/session_01QuUkTrofSjQ15j79mRuy4f --- .env.example | 17 ++ src/app/api/v1/nixamp/connection/route.ts | 41 +++ src/app/api/v1/nixamp/oauth/callback/route.ts | 96 +++++++ src/app/api/v1/nixamp/oauth/start/route.ts | 59 +++++ src/app/api/watch-party/nixamp/route.test.ts | 152 +++++++++++ src/app/api/watch-party/nixamp/route.ts | 150 +++++++++++ src/app/api/watch-party/route.ts | 12 +- src/app/settings/connections-section.tsx | 166 ++++++++++++ src/app/settings/settings-content.tsx | 10 +- src/app/watch-party/page.tsx | 22 +- src/components/watch-party/index.ts | 1 + src/components/watch-party/nixamp-panel.tsx | 200 ++++++++++++++ src/lib/nixamp/accounts.ts | 187 +++++++++++++ src/lib/nixamp/config.ts | 97 +++++++ src/lib/nixamp/index.ts | 55 ++++ src/lib/nixamp/oauth.test.ts | 212 +++++++++++++++ src/lib/nixamp/oauth.ts | 249 ++++++++++++++++++ src/lib/nixamp/rooms.ts | 197 ++++++++++++++ src/proxy.ts | 4 + .../20260911180000_nixamp_accounts.sql | 111 ++++++++ 20 files changed, 2032 insertions(+), 6 deletions(-) create mode 100644 src/app/api/v1/nixamp/connection/route.ts create mode 100644 src/app/api/v1/nixamp/oauth/callback/route.ts create mode 100644 src/app/api/v1/nixamp/oauth/start/route.ts create mode 100644 src/app/api/watch-party/nixamp/route.test.ts create mode 100644 src/app/api/watch-party/nixamp/route.ts create mode 100644 src/app/settings/connections-section.tsx create mode 100644 src/components/watch-party/nixamp-panel.tsx create mode 100644 src/lib/nixamp/accounts.ts create mode 100644 src/lib/nixamp/config.ts create mode 100644 src/lib/nixamp/index.ts create mode 100644 src/lib/nixamp/oauth.test.ts create mode 100644 src/lib/nixamp/oauth.ts create mode 100644 src/lib/nixamp/rooms.ts create mode 100644 supabase/migrations/20260911180000_nixamp_accounts.sql diff --git a/.env.example b/.env.example index dd822bf8..26980f14 100644 --- a/.env.example +++ b/.env.example @@ -255,6 +255,23 @@ GOOGLE_CLIENT_ID= GOOGLE_CLIENT_SECRET= GOOGLE_OAUTH_REDIRECT_URI=https://your-host/api/youtube/auth/callback +# nixamp (nixamp.com) — OAuth 2.1, with nixamp as the authorization server and +# bittorrented.com as the client. Connecting one lets a watch party here become +# a room on nixamp, joinable from every nixamp surface. +# +# 1. nixamp ships knowing `bittorrented` and its redirect URIs, so in production +# nothing below needs setting: the defaults are https://nixamp.com and +# /api/v1/nixamp/oauth/callback. +# 2. For a staging host, register it on the nixamp side with NIXAMP_OAUTH_CLIENTS +# and set NIXAMP_OAUTH_REDIRECT_URI here to match it exactly — OAuth 2.1 +# matches redirect URIs byte for byte. +# 3. NIXAMP_CLIENT_SECRET is only for a confidential registration. Leave it empty: +# PKCE (which is mandatory either way) is what protects the exchange. +NIXAMP_SITE=https://nixamp.com +NIXAMP_CLIENT_ID=bittorrented +NIXAMP_CLIENT_SECRET= +NIXAMP_OAUTH_REDIRECT_URI=https://your-host/api/v1/nixamp/oauth/callback + # DHT Crawler (Bitmagnet + DHT Search API) # Uses existing SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, SUPABASE_DB_PASSWORD # Set to false to skip DHT services installation during setup diff --git a/src/app/api/v1/nixamp/connection/route.ts b/src/app/api/v1/nixamp/connection/route.ts new file mode 100644 index 00000000..7e33f7cd --- /dev/null +++ b/src/app/api/v1/nixamp/connection/route.ts @@ -0,0 +1,41 @@ +/** + * GET /api/v1/nixamp/connection — is a nixamp account connected, and whose + * DELETE /api/v1/nixamp/connection — disconnect it, here and on nixamp + * + * What the settings page reads and what its Disconnect button calls. The + * answer never carries a token: the page needs to know that a connection + * exists and what to call the person, and nothing else. + */ + +import { NextResponse } from 'next/server'; +import { getCurrentUser } from '@/lib/auth'; +import { disconnectNixampAccount, getNixampAccount, isNixampConfigured } from '@/lib/nixamp'; + +export async function GET(): Promise { + const user = await getCurrentUser(); + if (!user) return NextResponse.json({ error: 'unauthorized' }, { status: 401 }); + + const account = await getNixampAccount(user.id); + return NextResponse.json({ + configured: isNixampConfigured(), + connected: Boolean(account), + ...(account + ? { + connection: { + // The public name on nixamp. Falling back to the id rather than + // the address, which is a credential over there. + handle: account.handle || account.nixampSub, + site: account.nixampSite, + scopes: account.scopes, + }, + } + : {}), + }); +} + +export async function DELETE(): Promise { + const user = await getCurrentUser(); + if (!user) return NextResponse.json({ error: 'unauthorized' }, { status: 401 }); + const gone = await disconnectNixampAccount(user.id); + return NextResponse.json({ success: true, disconnected: gone }); +} diff --git a/src/app/api/v1/nixamp/oauth/callback/route.ts b/src/app/api/v1/nixamp/oauth/callback/route.ts new file mode 100644 index 00000000..76b0881a --- /dev/null +++ b/src/app/api/v1/nixamp/oauth/callback/route.ts @@ -0,0 +1,96 @@ +/** + * GET /api/v1/nixamp/oauth/callback + * + * Where nixamp sends the browser back. This is the exact path registered with + * nixamp, which matches it byte for byte, so it is not something to rename + * lightly: the OAuth 2.1 rule is exact matching, with only a loopback port + * allowed to vary. + * + * The state and the PKCE verifier come out of the httpOnly cookie set on the + * way out, and the cookie is cleared however this ends: a verifier is worth + * exactly one exchange. + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { getCurrentUser } from '@/lib/auth'; +import { + discover, + exchangeCodeForTokens, + fetchUserInfo, + getNixampOAuthConfig, + NIXAMP_OAUTH_STATE_COOKIE, + upsertNixampAccount, +} from '@/lib/nixamp'; + +const SETTINGS = '/settings?tab=connections'; + +function back(origin: string, params: Record): NextResponse { + const url = new URL(SETTINGS, origin); + for (const [name, value] of Object.entries(params)) url.searchParams.set(name, value); + const res = NextResponse.redirect(url); + res.cookies.delete(NIXAMP_OAUTH_STATE_COOKIE); + return res; +} + +export async function GET(request: NextRequest): Promise { + // The public origin, from the configured redirect URI rather than + // request.url, which behind a proxy can be the internal bind address. + let origin: string; + let config; + try { + config = getNixampOAuthConfig(new URL(request.url).origin); + origin = new URL(config.redirectUri).origin; + } catch { + return back(new URL(request.url).origin, { nixamp_error: 'server_misconfigured' }); + } + + const user = await getCurrentUser(); + if (!user) return back(origin, { nixamp_error: 'not_authenticated' }); + + const { searchParams } = new URL(request.url); + const refused = searchParams.get('error'); + if (refused) return back(origin, { nixamp_error: refused }); + + const code = searchParams.get('code'); + const state = searchParams.get('state'); + if (!code || !state) return back(origin, { nixamp_error: 'missing_code_or_state' }); + + const cookie = request.cookies.get(NIXAMP_OAUTH_STATE_COOKIE)?.value; + let kept: { state?: string; verifier?: string } = {}; + try { + kept = cookie ? (JSON.parse(cookie) as typeof kept) : {}; + } catch { + kept = {}; + } + // Both halves, and the state compared rather than merely present: a + // callback whose state we did not issue is somebody replaying a URL. + if (!kept.state || !kept.verifier || kept.state !== state) { + return back(origin, { nixamp_error: 'state_mismatch' }); + } + + try { + const metadata = await discover(config); + const tokens = await exchangeCodeForTokens(config, metadata, code, kept.verifier); + const who = await fetchUserInfo(metadata, tokens.access_token); + + await upsertNixampAccount({ + userId: user.id, + nixampSub: who.sub, + nixampSite: config.site, + // The handle, never the address: on nixamp the account email is the + // linking key and is treated as a credential, and the handle is the + // name that is safe to show a room full of strangers. + ...(who.handle ? { handle: who.handle } : {}), + ...(who.email ? { email: who.email } : {}), + accessToken: tokens.access_token, + ...(tokens.refresh_token ? { refreshToken: tokens.refresh_token } : {}), + expiresIn: tokens.expires_in, + scopes: tokens.scope ? tokens.scope.split(' ').filter(Boolean) : [], + }); + + return back(origin, { nixamp: 'connected' }); + } catch (err) { + console.error('[nixamp OAuth] callback failed:', err); + return back(origin, { nixamp_error: 'exchange_failed' }); + } +} diff --git a/src/app/api/v1/nixamp/oauth/start/route.ts b/src/app/api/v1/nixamp/oauth/start/route.ts new file mode 100644 index 00000000..3b34f3c3 --- /dev/null +++ b/src/app/api/v1/nixamp/oauth/start/route.ts @@ -0,0 +1,59 @@ +/** + * GET /api/v1/nixamp/oauth/start + * + * Begins the OAuth 2.1 flow that connects a nixamp account to this one. + * nixamp is the authorization server; we are the client. + * + * Two secrets go out in one httpOnly cookie and neither ever reaches the + * browser's JavaScript: the CSRF state, which proves the callback belongs to + * a flow we started, and the PKCE verifier, which proves the code exchange is + * being done by whoever started it. OAuth 2.1 requires the second of every + * client, and it is what makes a client secret unnecessary here. + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { getCurrentUser } from '@/lib/auth'; +import { + buildAuthUrl, + codeChallenge, + discover, + generateCodeVerifier, + generateState, + getNixampOAuthConfig, + NIXAMP_OAUTH_STATE_COOKIE, + NIXAMP_OAUTH_STATE_MAX_AGE_SECONDS, +} from '@/lib/nixamp'; + +export async function GET(request: NextRequest): Promise { + const origin = new URL(request.url).origin; + const user = await getCurrentUser(); + if (!user) { + // Connecting is an act of an account: there has to be one to connect TO. + const back = new URL('/login', origin); + back.searchParams.set('redirect', '/settings?tab=connections'); + return NextResponse.redirect(back); + } + + let config; + try { + config = getNixampOAuthConfig(origin); + } catch (err) { + console.error('[nixamp OAuth] Missing config:', err); + return NextResponse.json({ error: 'The nixamp connection is not configured on this server.' }, { status: 500 }); + } + + const state = generateState(); + const verifier = generateCodeVerifier(); + const metadata = await discover(config); + const authUrl = buildAuthUrl(config, metadata, state, await codeChallenge(verifier)); + + const response = NextResponse.redirect(authUrl); + response.cookies.set(NIXAMP_OAUTH_STATE_COOKIE, JSON.stringify({ state, verifier }), { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + path: '/', + maxAge: NIXAMP_OAUTH_STATE_MAX_AGE_SECONDS, + }); + return response; +} diff --git a/src/app/api/watch-party/nixamp/route.test.ts b/src/app/api/watch-party/nixamp/route.test.ts new file mode 100644 index 00000000..4e97afa1 --- /dev/null +++ b/src/app/api/watch-party/nixamp/route.test.ts @@ -0,0 +1,152 @@ +/** + * The bridge endpoint: who may put a party on nixamp, and who may only read + * where the room is. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { NextRequest } from 'next/server'; + +const auth = vi.hoisted(() => ({ getCurrentUser: vi.fn() })); +const store = vi.hoisted(() => ({ getParty: vi.fn() })); +const nixamp = vi.hoisted(() => ({ + bridgeParty: vi.fn(), + endBridgedParty: vi.fn(), + getBridgedRoom: vi.fn(), + pushPlayback: vi.fn(), + NixampNotConnected: class NixampNotConnected extends Error {}, + NixampConnectionLost: class NixampConnectionLost extends Error {}, +})); + +vi.mock('@/lib/auth', () => auth); +vi.mock('../_store', () => store); +vi.mock('@/lib/nixamp', () => nixamp); +vi.mock('@/lib/watch-party', () => ({ + validatePartyCode: (code: string) => /^[A-Z0-9]{6}$/.test(code), +})); + +const { GET, POST } = await import('./route'); + +const room = { + partyCode: 'ABC123', + nixampSite: 'https://nixamp.test', + eventId: 'event-1', + roomId: 'event-abc', + slug: 'dune-together', + nixampUrl: 'https://nixamp.test/live/dune-together', +}; + +const party = { + code: 'ABC123', + hostId: 'user-1', + mediaTitle: 'Dune (2021)', + playback: { currentTime: 930, isPlaying: true, duration: 9000, lastUpdate: Date.now() }, +}; + +function post(body: unknown): NextRequest { + return new NextRequest('https://bittorrented.test/api/watch-party/nixamp', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }); +} + +beforeEach(() => { + vi.clearAllMocks(); + auth.getCurrentUser.mockResolvedValue({ id: 'user-1', email: 'a@b.test' }); + store.getParty.mockReturnValue(party); + nixamp.getBridgedRoom.mockResolvedValue(null); + nixamp.bridgeParty.mockResolvedValue(room); + nixamp.pushPlayback.mockResolvedValue(true); + nixamp.endBridgedParty.mockResolvedValue(true); +}); + +describe('GET', () => { + it('tells anybody where the room is, without an account', async () => { + auth.getCurrentUser.mockResolvedValue(null); + nixamp.getBridgedRoom.mockResolvedValue(room); + const res = await GET(new NextRequest('https://bittorrented.test/api/watch-party/nixamp?code=ABC123')); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.bridged).toBe(true); + expect(body.room.nixampUrl).toBe(room.nixampUrl); + }); + + it('answers "not bridged" rather than an error, because most parties are not', async () => { + const res = await GET(new NextRequest('https://bittorrented.test/api/watch-party/nixamp?code=ABC123')); + expect(res.status).toBe(200); + expect((await res.json()).bridged).toBe(false); + }); + + it('will not take a code that is not one', async () => { + const res = await GET(new NextRequest('https://bittorrented.test/api/watch-party/nixamp?code=nope')); + expect(res.status).toBe(400); + }); +}); + +describe('POST', () => { + it('needs somebody signed in', async () => { + auth.getCurrentUser.mockResolvedValue(null); + const res = await POST(post({ code: 'ABC123' })); + expect(res.status).toBe(401); + expect(nixamp.bridgeParty).not.toHaveBeenCalled(); + }); + + it('refuses a member who is not the host of the party', async () => { + auth.getCurrentUser.mockResolvedValue({ id: 'user-2', email: 'c@d.test' }); + const res = await POST(post({ code: 'ABC123' })); + expect(res.status).toBe(403); + expect(nixamp.bridgeParty).not.toHaveBeenCalled(); + }); + + it('bridges for the host and answers the room link', async () => { + const res = await POST(post({ code: 'ABC123' })); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.room.nixampUrl).toBe(room.nixampUrl); + expect(nixamp.bridgeParty).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'user-1', partyCode: 'ABC123', mediaTitle: 'Dune (2021)' }) + ); + }); + + it('sends nixamp somewhere real when a sync says nothing about where it is', async () => { + const res = await POST(post({ code: 'ABC123', action: 'sync' })); + expect(res.status).toBe(200); + // Falls back to the party's own recorded playback rather than 0, which + // would drag everybody on nixamp back to the start of the film. + expect(nixamp.pushPlayback).toHaveBeenCalledWith( + expect.objectContaining({ positionSeconds: 930, playing: true }) + ); + }); + + it('reports a sync that did not land without calling the party broken', async () => { + nixamp.pushPlayback.mockResolvedValue(false); + const res = await POST(post({ code: 'ABC123', action: 'sync', positionSeconds: 12, playing: false })); + expect(res.status).toBe(200); + expect((await res.json()).synced).toBe(false); + }); + + it('points at the connect flow when no nixamp account is connected', async () => { + nixamp.bridgeParty.mockRejectedValue(new nixamp.NixampNotConnected()); + const res = await POST(post({ code: 'ABC123' })); + expect(res.status).toBe(409); + expect((await res.json()).connect).toBe('/api/v1/nixamp/oauth/start'); + }); + + it('says to connect again when the grant has been withdrawn', async () => { + nixamp.bridgeParty.mockRejectedValue(new nixamp.NixampConnectionLost()); + const res = await POST(post({ code: 'ABC123' })); + expect(res.status).toBe(409); + expect((await res.json()).error).toMatch(/connection has ended/i); + }); + + it('404s for a party this process has never heard of', async () => { + store.getParty.mockReturnValue(undefined); + const res = await POST(post({ code: 'ABC123' })); + expect(res.status).toBe(404); + }); + + it('will not take an action it does not have', async () => { + const res = await POST(post({ code: 'ABC123', action: 'delete-everything' })); + expect(res.status).toBe(400); + }); +}); diff --git a/src/app/api/watch-party/nixamp/route.ts b/src/app/api/watch-party/nixamp/route.ts new file mode 100644 index 00000000..692fe621 --- /dev/null +++ b/src/app/api/watch-party/nixamp/route.ts @@ -0,0 +1,150 @@ +/** + * The bridge between a watch party here and a room on nixamp. + * + * GET /api/watch-party/nixamp?code=ABC123 where the room is (public) + * POST /api/watch-party/nixamp bridge, sync or end (host only) + * + * The film stays here; the room goes there. Once a party is bridged, anybody + * on nixamp -- the web app, the terminal, the desktop app, a television, an + * agent over MCP -- can find it and be in the room with the people watching + * in this browser. + * + * GET is public on purpose: a member who was handed a code needs the room + * link, and that link is not a secret -- the room's own visibility on nixamp + * is what decides who may be in it. + * + * POST is the host, proven twice: the signed-in user must be the one whose + * nixamp account is connected, and must be the host of the party as the + * in-memory store has it. Neither alone is enough -- the store's hostId can + * be a guest string, and a connected nixamp account says nothing about which + * party is yours. + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { getCurrentUser } from '@/lib/auth'; +import { validatePartyCode } from '@/lib/watch-party'; +import { + NixampConnectionLost, + NixampNotConnected, + bridgeParty, + endBridgedParty, + getBridgedRoom, + pushPlayback, +} from '@/lib/nixamp'; +import { getParty } from '../_store'; + +interface BridgeBody { + code?: string; + action?: 'bridge' | 'sync' | 'end'; + title?: string; + mediaTitle?: string; + positionSeconds?: number; + playing?: boolean; +} + +function cleanCode(value: string | null | undefined): string | null { + const code = (value ?? '').trim().toUpperCase(); + return code && validatePartyCode(code) ? code : null; +} + +export async function GET(request: NextRequest): Promise { + const code = cleanCode(new URL(request.url).searchParams.get('code')); + if (!code) return NextResponse.json({ error: 'A valid party code is required' }, { status: 400 }); + + const room = await getBridgedRoom(code); + if (!room) { + // Not an error: most parties are never bridged, and the page asks about + // every one it shows. + return NextResponse.json({ success: true, bridged: false }); + } + return NextResponse.json({ + success: true, + bridged: true, + room: { nixampUrl: room.nixampUrl, roomId: room.roomId, slug: room.slug, site: room.nixampSite }, + }); +} + +export async function POST(request: NextRequest): Promise { + const user = await getCurrentUser(); + if (!user) { + return NextResponse.json( + { error: 'Sign in, then connect a nixamp account, to put this party on nixamp.' }, + { status: 401 } + ); + } + + let body: BridgeBody; + try { + body = (await request.json()) as BridgeBody; + } catch { + return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }); + } + + const code = cleanCode(body.code); + if (!code) return NextResponse.json({ error: 'A valid party code is required' }, { status: 400 }); + + const party = getParty(code); + if (!party) return NextResponse.json({ error: 'Party not found' }, { status: 404 }); + if (party.hostId !== user.id) { + return NextResponse.json({ error: 'Only the host can put this party on nixamp' }, { status: 403 }); + } + + const origin = new URL(request.url).origin; + const action = body.action ?? 'bridge'; + + try { + if (action === 'bridge') { + const room = await bridgeParty({ + userId: user.id, + partyCode: code, + title: body.title ?? party.mediaTitle ?? `Watch party ${code}`, + mediaTitle: body.mediaTitle ?? party.mediaTitle ?? '', + origin, + }); + return NextResponse.json({ + success: true, + bridged: true, + room: { nixampUrl: room.nixampUrl, roomId: room.roomId, slug: room.slug, site: room.nixampSite }, + }); + } + + if (action === 'sync') { + // Where the host's own player is. Falling back to the party's recorded + // playback so a client that only says "sync" still says something true. + const positionSeconds = + typeof body.positionSeconds === 'number' ? body.positionSeconds : party.playback.currentTime; + const playing = typeof body.playing === 'boolean' ? body.playing : party.playback.isPlaying; + const landed = await pushPlayback({ + userId: user.id, + partyCode: code, + positionSeconds, + playing, + ...(body.mediaTitle ?? party.mediaTitle ? { mediaTitle: body.mediaTitle ?? party.mediaTitle } : {}), + }); + // A sync that did not land is not a failure of the party: everybody in + // this browser is still together. It is only nixamp that is behind. + return NextResponse.json({ success: true, synced: landed }); + } + + if (action === 'end') { + return NextResponse.json({ success: true, ended: await endBridgedParty(user.id, code) }); + } + + return NextResponse.json({ error: 'action must be bridge, sync or end' }, { status: 400 }); + } catch (error) { + if (error instanceof NixampNotConnected) { + return NextResponse.json( + { error: 'Connect your nixamp account first.', connect: '/api/v1/nixamp/oauth/start' }, + { status: 409 } + ); + } + if (error instanceof NixampConnectionLost) { + return NextResponse.json( + { error: 'That nixamp connection has ended. Connect it again.', connect: '/api/v1/nixamp/oauth/start' }, + { status: 409 } + ); + } + console.error('[WatchParty] nixamp bridge failed:', error); + return NextResponse.json({ error: 'Could not reach nixamp' }, { status: 502 }); + } +} diff --git a/src/app/api/watch-party/route.ts b/src/app/api/watch-party/route.ts index b7907e06..319ab4aa 100644 --- a/src/app/api/watch-party/route.ts +++ b/src/app/api/watch-party/route.ts @@ -10,6 +10,7 @@ */ import { NextRequest, NextResponse } from 'next/server'; +import { getCurrentUser } from '@/lib/auth'; import { createWatchParty, validatePartyCode, @@ -42,8 +43,15 @@ export async function POST(request: NextRequest): Promise { ); } - // Generate a guest ID if not provided (for anonymous users) - const hostId = body.hostId ?? `guest_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`; + // Whoever is signed in IS the host, ahead of anything the body claims. + // + // A party still needs no account -- an anonymous host gets a guest id as + // before -- but a signed-in one gets their real user id, and that is what + // makes the nixamp bridge possible: putting the party on nixamp has to be + // provably the host's doing, and a guest string proves nothing. + const user = await getCurrentUser(); + const hostId = + user?.id ?? body.hostId ?? `guest_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`; // Create the party options const options: CreatePartyOptions = { diff --git a/src/app/settings/connections-section.tsx b/src/app/settings/connections-section.tsx new file mode 100644 index 00000000..f70df701 --- /dev/null +++ b/src/app/settings/connections-section.tsx @@ -0,0 +1,166 @@ +'use client'; + +/** + * Connected accounts — other places that act on your behalf, or you on theirs. + * + * Today that is nixamp: bittorrented.com is an OAuth 2.1 client of nixamp.com, + * so connecting one lets a watch party here become a room there, joinable from + * every nixamp surface. + * + * The name shown is the nixamp HANDLE, never the address. On nixamp the + * account email is the OAuth linking key and is treated as a credential; the + * handle is the name that is safe to show. + */ + +import { useCallback, useEffect, useState } from 'react'; +import { useSearchParams } from 'next/navigation'; +import { cn } from '@/lib/utils'; +import { CheckIcon, LinkIcon, LoadingSpinner, TrashIcon } from '@/components/ui/icons'; + +interface Connection { + handle: string; + site: string; + scopes: string[]; +} + +interface ConnectionResponse { + configured?: boolean; + connected?: boolean; + connection?: Connection; + error?: string; +} + +/** The words the callback puts in the URL, in words a person would use. */ +const REASONS: Record = { + access_denied: 'You said not now on nixamp. Nothing was connected.', + state_mismatch: 'That sign-in link had expired. Try connecting again.', + not_authenticated: 'You were signed out partway through. Sign in and try again.', + server_misconfigured: 'This server has no nixamp settings yet.', + exchange_failed: 'nixamp would not finish the connection. Try again.', + missing_code_or_state: 'nixamp sent an incomplete answer. Try again.', +}; + +export function ConnectionsSection(): React.ReactElement { + const searchParams = useSearchParams(); + const [state, setState] = useState(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + const load = useCallback(async (): Promise => { + try { + const res = await fetch('/api/v1/nixamp/connection'); + const body = (await res.json()) as ConnectionResponse; + return res.ok ? body : { connected: false, configured: false }; + } catch { + return { connected: false, configured: false }; + } + }, []); + + useEffect(() => { + let alive = true; + void (async () => { + const answer = await load(); + if (alive) setState(answer); + })(); + return () => { + alive = false; + }; + }, [load]); + + const disconnect = useCallback(async () => { + setBusy(true); + setError(null); + try { + const res = await fetch('/api/v1/nixamp/connection', { method: 'DELETE' }); + if (!res.ok) setError('Could not disconnect. Try again.'); + setState(await load()); + } finally { + setBusy(false); + } + }, [load]); + + const justConnected = searchParams.get('nixamp') === 'connected'; + const refusal = searchParams.get('nixamp_error'); + + if (state === null) { + return ( +
+ Checking your connections… +
+ ); + } + + return ( +
+
+

Connected accounts

+

+ Other places that can act for you here, or that you can act on from here. +

+
+ + {justConnected ? ( +

+ nixamp is connected. +

+ ) : null} + {refusal ? ( +

{REASONS[refusal] ?? `nixamp said: ${refusal}`}

+ ) : null} + +
+
+
+

+ nixamp +

+

+ {state.connected + ? 'A watch party here can be a room on nixamp, joinable from the nixamp app, a terminal, the desktop app or a TV.' + : 'Connect nixamp and a watch party here becomes a room people can join from any nixamp client.'} +

+ {state.connected && state.connection ? ( +

+ Connected as{' '} + @{state.connection.handle} on{' '} + {state.connection.site.replace(/^https?:\/\//, '')} + {state.connection.scopes.length > 0 ? ` · ${state.connection.scopes.join(', ')}` : ''} +

+ ) : null} +
+ + {state.connected ? ( + + ) : ( + // A link and not a fetch: connecting is a round trip through + // nixamp's own consent page, which is the only place the decision + // can honestly be made. + + {state.configured ? 'Connect nixamp' : 'Not configured'} + + )} +
+
+ + {error ?

{error}

: null} +
+ ); +} diff --git a/src/app/settings/settings-content.tsx b/src/app/settings/settings-content.tsx index 76496097..ec2324b9 100644 --- a/src/app/settings/settings-content.tsx +++ b/src/app/settings/settings-content.tsx @@ -11,12 +11,13 @@ import { useState, useEffect, useCallback } from 'react'; import { useRouter, useSearchParams } from 'next/navigation'; import { MainLayout } from '@/components/layout'; import { cn } from '@/lib/utils'; -import { SettingsIcon, UserIcon, TvIcon, VideoIcon, TrashIcon, ExternalLinkIcon, LoadingSpinner, MailIcon } from '@/components/ui/icons'; +import { SettingsIcon, UserIcon, TvIcon, VideoIcon, TrashIcon, ExternalLinkIcon, LoadingSpinner, MailIcon, LinkIcon } from '@/components/ui/icons'; import { useAuth } from '@/hooks/use-auth'; import Link from 'next/link'; import { EmailAccountsSection } from './email-accounts-section'; +import { ConnectionsSection } from './connections-section'; -type SettingsTab = 'account' | 'playback' | 'iptv' | 'email'; +type SettingsTab = 'account' | 'playback' | 'iptv' | 'email' | 'connections'; /** * IPTV Playlist data from API @@ -56,11 +57,12 @@ export function SettingsContent(): React.ReactElement { { id: 'playback' as const, label: 'Playback', icon: VideoIcon }, { id: 'iptv' as const, label: 'IPTV', icon: TvIcon }, { id: 'email' as const, label: 'Email', icon: MailIcon }, + { id: 'connections' as const, label: 'Connections', icon: LinkIcon }, ]; const tabParam = searchParams.get('tab'); const activeTab: SettingsTab = - tabParam === 'email' || tabParam === 'playback' || tabParam === 'iptv' + tabParam === 'email' || tabParam === 'playback' || tabParam === 'iptv' || tabParam === 'connections' ? tabParam : 'account'; @@ -388,6 +390,8 @@ export function SettingsContent(): React.ReactElement { )} {activeTab === 'email' && } + + {activeTab === 'connections' && } diff --git a/src/app/watch-party/page.tsx b/src/app/watch-party/page.tsx index 417820d9..93228095 100644 --- a/src/app/watch-party/page.tsx +++ b/src/app/watch-party/page.tsx @@ -7,8 +7,9 @@ * Free for anyone without requiring login. */ -import { useState, useCallback } from 'react'; +import { useState, useCallback, useRef } from 'react'; import { MainLayout } from '@/components/layout'; +import { NixampPanel } from '@/components/watch-party'; import { cn } from '@/lib/utils'; import { PartyIcon, PlusIcon, UsersIcon } from '@/components/ui/icons'; @@ -65,6 +66,9 @@ export default function WatchPartyPage(): React.ReactElement { const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); const [, setIsMediaModalOpen] = useState(false); + // The host's own player, so its position can be told to nixamp. Everybody + // else follows; only the host states where the film is. + const videoRef = useRef(null); const handleCreateParty = useCallback(async () => { if (!hostName.trim()) { @@ -224,6 +228,7 @@ export default function WatchPartyPage(): React.ReactElement { )}> {party.mediaUrl ? (