Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
# <NEXT_PUBLIC_APP_URL>/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
Expand Down
41 changes: 41 additions & 0 deletions src/app/api/v1/nixamp/connection/route.ts
Original file line number Diff line number Diff line change
@@ -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<NextResponse> {
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<NextResponse> {
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 });
}
96 changes: 96 additions & 0 deletions src/app/api/v1/nixamp/oauth/callback/route.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>): 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<Response> {
// 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' });
}
}
59 changes: 59 additions & 0 deletions src/app/api/v1/nixamp/oauth/start/route.ts
Original file line number Diff line number Diff line change
@@ -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<Response> {
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;
}
152 changes: 152 additions & 0 deletions src/app/api/watch-party/nixamp/route.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading
Loading