Move livekit auth to relay

This commit is contained in:
mplorentz
2026-03-02 17:00:19 -05:00
committed by hodlbod
parent 3049efe889
commit 52f2f31ce6
8 changed files with 386 additions and 82 deletions
+16 -6
View File
@@ -55,6 +55,8 @@
notificationSettings,
deriveShouldNotify,
displayRoom,
roomHasLivekit,
roomIsNoText,
} from "@app/core/state"
import {setSpaceNotifications} from "@app/core/commands"
import {pushModal} from "@app/util/modal"
@@ -257,9 +259,13 @@
<div class="h-2"></div>
<SecondaryNavHeader>Your Rooms</SecondaryNavHeader>
{/if}
{#each $userRooms as h, i (h)}
<SpaceMenuRoomItem notify {replaceState} {url} {h} />
<VoiceRoomItem {url} {h} />
{#each $userRooms as h (h)}
{#if !roomIsNoText(url, h)}
<SpaceMenuRoomItem notify {replaceState} {url} {h} />
{/if}
{#if roomHasLivekit(url, h)}
<VoiceRoomItem {url} {h} />
{/if}
{/each}
{#if $otherRooms.length > 0}
<div class="h-2"></div>
@@ -277,9 +283,13 @@
<input bind:value={term} onblur={clearTerm} class="grow" />
</label>
{/if}
{#each $roomSearch.searchValues(term) as h, i (h)}
<SpaceMenuRoomItem {replaceState} {url} {h} />
<VoiceRoomItem {url} {h} />
{#each $roomSearch.searchValues(term) as h (h)}
{#if !roomIsNoText(url, h)}
<SpaceMenuRoomItem {replaceState} {url} {h} />
{/if}
{#if roomHasLivekit(url, h)}
<VoiceRoomItem {url} {h} />
{/if}
{/each}
{#if $canCreateRoom}
<SecondaryNavItem {replaceState} onclick={addRoom}>
+10
View File
@@ -663,6 +663,16 @@ export const displayRoom = (url: string, h: string) => getRoom(makeRoomId(url, h
export const roomComparator = (url: string) => (h: string) => displayRoom(url, h).toLowerCase()
export const roomHasLivekit = (url: string, h: string) => {
const room = getRoom(makeRoomId(url, h))
return room?.event?.tags?.some(t => t[0] === "livekit") ?? false
}
export const roomIsNoText = (url: string, h: string) => {
const room = getRoom(makeRoomId(url, h))
return room?.event?.tags?.some(t => t[0] === "no-text") ?? false
}
// User space/room lists
export const groupListsByPubkey = deriveItemsByKey({
+2 -2
View File
@@ -55,7 +55,7 @@ import {
loadFeedsForPubkey,
} from "@app/core/state"
import {hasBlossomSupport} from "@app/core/commands"
import {LIVE_ACTIVITY, ROOM_PRESENCE} from "@app/voice"
import {ROOM_PRESENCE} from "@app/voice"
// Utils
@@ -320,7 +320,7 @@ const syncSpace = (url: string, rooms: string[]) => {
pullAndListen({
url,
signal: controller.signal,
filters: [{kinds: [LIVE_ACTIVITY, ROOM_PRESENCE]}],
filters: [{kinds: [ROOM_PRESENCE]}],
})
return () => controller.abort()
+38 -67
View File
@@ -1,18 +1,12 @@
import {derived, get, writable} from "svelte/store"
import * as nip19 from "nostr-tools/nip19"
import {SignJWT} from "jose"
import {Room, RoomEvent, Track} from "livekit-client"
import {now} from "@welshman/lib"
import {makeEvent, normalizeRelayUrl, getTag} from "@welshman/util"
import {pubkey, publishThunk} from "@welshman/app"
import {deriveEventsForUrl, displayRoom} from "@app/core/state"
import {makeEvent, getTagValue} from "@welshman/util"
import {signer, publishThunk} from "@welshman/app"
import {deriveEventsForUrl} from "@app/core/state"
export const LIVE_ACTIVITY = 30311
export const ROOM_PRESENCE = 10312
const LIVEKIT_URL = import.meta.env.VITE_LIVEKIT_URL || ""
const LIVEKIT_API_KEY = import.meta.env.VITE_LIVEKIT_API_KEY || ""
const LIVEKIT_API_SECRET = import.meta.env.VITE_LIVEKIT_API_SECRET || ""
const PRESENCE_INTERVAL_MS = 60_000
const PRESENCE_EXPIRY_S = 300
@@ -25,23 +19,40 @@ export type VoiceSession = {
export const currentVoiceSession = writable<VoiceSession | undefined>(undefined)
const makeLivekitRoomName = (url: string, h: string) =>
`${normalizeRelayUrl(url)}:${h}`.replace(/[^a-zA-Z0-9_-]/g, "_")
const buildNip98AuthEvent = async (url: string, method: string) => {
const $signer = signer.get()
if (!$signer) throw new Error("No signer available")
const generateToken = async (roomName: string, identity: string) => {
const secret = new TextEncoder().encode(LIVEKIT_API_SECRET)
const jwt = await new SignJWT({
video: {roomJoin: true, room: roomName, canPublish: true, canSubscribe: true},
sub: identity,
iss: LIVEKIT_API_KEY,
jti: identity,
const event = makeEvent(27235, {
tags: [
["u", url],
["method", method],
],
})
.setProtectedHeader({alg: "HS256"})
.setIssuedAt()
.setExpirationTime("6h")
.sign(secret)
return jwt
return $signer.sign(event)
}
const fetchLivekitToken = async (
url: string,
groupId: string,
): Promise<{server_url: string; participant_token: string}> => {
const httpUrl = url.replace(/^wss:\/\//, "https://").replace(/^ws:\/\//, "http://")
const endpoint = `${httpUrl}/.well-known/nip29/livekit/${groupId}`
const authEvent = await buildNip98AuthEvent(endpoint, "GET")
const encoded = btoa(JSON.stringify(authEvent))
const response = await fetch(endpoint, {
headers: {Authorization: `Nostr ${encoded}`},
})
if (!response.ok) {
const text = await response.text()
throw new Error(`Token request failed (${response.status}): ${text}`)
}
return response.json()
}
export const deriveVoiceParticipants = (url: string, h: string) =>
@@ -52,11 +63,7 @@ export const deriveVoiceParticipants = (url: string, h: string) =>
for (const event of $events) {
if (event.created_at < cutoff) continue
const aTag = getTag("a", event.tags)
if (!aTag) continue
const [, , dTag] = aTag[1].split(":")
if (dTag === h) {
if (getTagValue("h", event.tags) === h) {
pubkeys.push(event.pubkey)
}
}
@@ -64,29 +71,9 @@ export const deriveVoiceParticipants = (url: string, h: string) =>
return pubkeys
})
const publishLiveActivity = (url: string, h: string, status: "live" | "ended") => {
const pk = get(pubkey)!
const title = displayRoom(url, h)
const event = makeEvent(LIVE_ACTIVITY, {
tags: [
["d", h],
["h", h],
["title", title],
["service", LIVEKIT_URL],
["status", status],
["starts", String(now())],
["p", pk, "", "Host"],
],
})
return publishThunk({event, relays: [url]})
}
const publishPresence = (url: string, h: string) => {
const pk = get(pubkey)!
const aTag = `${LIVE_ACTIVITY}:${pk}:${h}`
const event = makeEvent(ROOM_PRESENCE, {
tags: [["a", aTag, url, "root"]],
tags: [["h", h]],
})
return publishThunk({event, relays: [url]})
@@ -121,10 +108,7 @@ export const joinVoiceRoom = async (url: string, h: string) => {
await leaveVoiceRoom()
}
const pk = get(pubkey)!
const identity = nip19.npubEncode(pk)
const roomName = makeLivekitRoomName(url, h)
const token = await generateToken(roomName, identity)
const {server_url, participant_token} = await fetchLivekitToken(url, h)
const room = new Room({
adaptiveStream: true,
@@ -149,12 +133,11 @@ export const joinVoiceRoom = async (url: string, h: string) => {
track.detach().forEach(el => el.remove())
})
await room.connect(LIVEKIT_URL, token)
await room.connect(server_url, participant_token)
await room.localParticipant.setMicrophoneEnabled(true)
currentVoiceSession.set({url, h, room, muted: false})
publishLiveActivity(url, h, "live")
startPresenceHeartbeat(url, h)
}
@@ -165,7 +148,6 @@ export const leaveVoiceRoom = async () => {
stopPresenceHeartbeat()
session.room.disconnect()
deletePresence(session.url)
publishLiveActivity(session.url, session.h, "ended")
currentVoiceSession.set(undefined)
}
@@ -177,14 +159,3 @@ export const toggleMute = () => {
session.room.localParticipant.setMicrophoneEnabled(!muted)
currentVoiceSession.set({...session, muted})
}
export const toggleDeafen = () => {
const session = get(currentVoiceSession)
if (!session) return
for (const participant of session.room.remoteParticipants.values()) {
for (const pub of participant.audioTrackPublications.values()) {
pub.setEnabled(!pub.isEnabled)
}
}
}