diff --git a/.env.example b/.env.example index e6fd8a1..cd26729 100644 --- a/.env.example +++ b/.env.example @@ -46,6 +46,11 @@ TURNS_SERVER_URL=turns:turn.pairux.com:5349 # =========================================== LIVEKIT_API_KEY=devkey LIVEKIT_API_SECRET=secret + +# Partner apps (e.g. qrypt.chat) that run end-to-end encrypted calls through +# @profullstack/pairux-embed and POST /api/v1/partner/token. Only the SHA-256 +# of each partner key (pux_pk_...) is stored here; see apps/web/src/lib/partners.ts. +# PAIRUX_PARTNERS=[{"id":"qrypt","name":"qrypt.chat","keySha256":"","maxParticipants":16}] # Local dev: ws://localhost:7880 # Production: wss://sfu.pairux.com NEXT_PUBLIC_LIVEKIT_URL=ws://localhost:7880 diff --git a/.gitleaksignore b/.gitleaksignore index a771fc9..5508c8f 100644 --- a/.gitleaksignore +++ b/.gitleaksignore @@ -9,3 +9,7 @@ # gitignored so local settings are never committed again. a301ecc80cdc48ccb7a9e3050235089db787701b:.claude/settings.local.json:generic-api-key:91 f70a3ef1f2770499c3aea1b3e1cf98641eb44e87:.claude/settings.local.json:generic-api-key:91 + +# A fake partner key in a test (pux_pk_qrypt_test_key_…), never a real one; the +# test now builds its keys at runtime. Commit 3cdbdbe, PR #133. +3cdbdbefeb57ea51f068a9552fb5369a5c2dcac9:apps/web/src/app/api/v1/partner/token/route.test.ts:generic-api-key:16 diff --git a/apps/web/src/app/api/v1/partner/token/route.test.ts b/apps/web/src/app/api/v1/partner/token/route.test.ts new file mode 100644 index 0000000..ec28c80 --- /dev/null +++ b/apps/web/src/app/api/v1/partner/token/route.test.ts @@ -0,0 +1,98 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { hashPartnerKey, partnersFromEnv, authenticatePartner } from '@/lib/partners'; + +const mockAddGrant = vi.fn(); +const mockCtor = vi.fn(); +vi.mock('livekit-server-sdk', () => ({ + AccessToken: vi.fn().mockImplementation((...args: unknown[]) => { + mockCtor(...args); + return { addGrant: mockAddGrant, toJwt: () => Promise.resolve('jwt') }; + }), +})); +vi.mock('@/lib/ice-servers', () => ({ + getIceServers: () => Promise.resolve([{ urls: 'turn:turn.pairux.com:3478' }]), +})); + +// Built at runtime: a key-shaped literal trips secret scanners, and this one is fake. +const KEY = ['pux', 'pk', 'q'.repeat(32)].join('_'); +const WRONG_KEY = ['pux', 'pk', 'w'.repeat(32)].join('_'); +const req = (body: unknown, key: string | null = KEY) => + new Request('https://pairux.com/api/v1/partner/token', { + method: 'POST', + headers: { + 'content-type': 'application/json', + ...(key ? { authorization: `Bearer ${key}` } : {}), + }, + body: JSON.stringify(body), + }); + +beforeEach(() => { + vi.stubEnv( + 'PAIRUX_PARTNERS', + JSON.stringify([ + { id: 'qrypt', name: 'qrypt.chat', keySha256: hashPartnerKey(KEY), maxParticipants: 8 }, + ]) + ); + vi.stubEnv('LIVEKIT_API_KEY', 'k'); + vi.stubEnv('LIVEKIT_API_SECRET', 's'); + vi.stubEnv('NEXT_PUBLIC_LIVEKIT_URL', 'wss://sfu.pairux.com'); + mockAddGrant.mockClear(); + mockCtor.mockClear(); +}); + +describe('partner keys', () => { + it('store only a hash and match in constant time', () => { + const partners = partnersFromEnv(); + expect(partners).toEqual([ + { id: 'qrypt', name: 'qrypt.chat', keySha256: hashPartnerKey(KEY), maxParticipants: 8 }, + ]); + expect(JSON.stringify(partners)).not.toContain(KEY); + expect(authenticatePartner(req({}), partners)?.id).toBe('qrypt'); + expect(authenticatePartner(req({}, WRONG_KEY), partners)).toBeNull(); + expect(authenticatePartner(req({}, null), partners)).toBeNull(); + }); + + it('ignore malformed config', () => { + expect(partnersFromEnv('not json')).toEqual([]); + expect(partnersFromEnv(JSON.stringify([{ id: 'Bad Id', keySha256: 'x' }]))).toEqual([]); + }); +}); + +describe('POST /api/v1/partner/token', () => { + it('mints a token for a room in the partner namespace', async () => { + const { POST } = await import('./route'); + const res = await POST(req({ room: 'conv-123', identity: 'user-9', name: 'Alice' })); + expect(res.status).toBe(200); + const json = (await res.json()) as { data?: Record } & Record; + const data = json.data ?? json; + expect(data).toMatchObject({ + token: 'jwt', + url: 'wss://sfu.pairux.com', + roomName: 'partner-qrypt-conv-123', + e2ee: true, + maxParticipants: 8, + }); + expect(mockCtor).toHaveBeenCalledWith( + 'k', + 's', + expect.objectContaining({ identity: 'qrypt:user-9', name: 'Alice', ttl: '6h' }) + ); + expect(mockAddGrant).toHaveBeenCalledWith( + expect.objectContaining({ room: 'partner-qrypt-conv-123', roomJoin: true, canPublish: true }) + ); + }); + + it('refuses a missing or wrong key', async () => { + const { POST } = await import('./route'); + expect((await POST(req({ room: 'r', identity: 'u', name: 'A' }, null))).status).toBe(401); + expect((await POST(req({ room: 'r', identity: 'u', name: 'A' }, WRONG_KEY))).status).toBe(401); + expect(mockCtor).not.toHaveBeenCalled(); + }); + + it('refuses a room id that could escape the namespace', async () => { + const { POST } = await import('./route'); + const res = await POST(req({ room: '../session-abc', identity: 'u', name: 'A' })); + expect(res.status).toBe(400); + expect(mockCtor).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/app/api/v1/partner/token/route.ts b/apps/web/src/app/api/v1/partner/token/route.ts new file mode 100644 index 0000000..9f0f7ae --- /dev/null +++ b/apps/web/src/app/api/v1/partner/token/route.ts @@ -0,0 +1,87 @@ +import { z } from 'zod'; +import { AccessToken } from 'livekit-server-sdk'; +import type { VideoGrant } from 'livekit-server-sdk'; +import { successResponse, errorResponse, handleApiError } from '@/lib/api'; +import { getIceServers } from '@/lib/ice-servers'; +import { FixedWindowRateLimiter, getClientIp } from '@/lib/rate-limit'; +import { authenticatePartner, partnerRoomName } from '@/lib/partners'; + +/** + * POST /api/v1/partner/token: a partner app's SERVER mints a LiveKit token for + * one of its users, for an end-to-end encrypted call run with + * @profullstack/pairux-embed. See src/lib/partners.ts. + * + * Authorization: Bearer pux_pk_… + * { "room": "", "identity": "", "name": "Alice" } + * -> { token, url, roomName, iceServers, e2ee: true } + * + * The media key never comes here: partners share it between participants + * themselves, so PairUX forwards only ciphertext. + */ +const requestsByIp = new FixedWindowRateLimiter(60, 60_000); +const requestsByPartner = new FixedWindowRateLimiter(600, 60_000); + +const bodySchema = z.object({ + room: z.string().regex(/^[A-Za-z0-9_-]{1,64}$/, 'room: 1-64 letters, digits, _ or -'), + identity: z.string().regex(/^[A-Za-z0-9_.:@-]{1,64}$/, 'identity: 1-64 safe characters'), + name: z.string().trim().min(1).max(50), +}); + +export async function POST(request: Request) { + try { + const ipLimit = requestsByIp.check(getClientIp(request)); + if (!ipLimit.success) { + return errorResponse( + `Too many requests. Try again in ${String(ipLimit.retryAfterSeconds)} seconds.`, + 429 + ); + } + + const partner = authenticatePartner(request); + if (!partner) return errorResponse('Unknown or missing partner key', 401); + + const partnerLimit = requestsByPartner.check(partner.id); + if (!partnerLimit.success) { + return errorResponse( + `Too many requests. Try again in ${String(partnerLimit.retryAfterSeconds)} seconds.`, + 429 + ); + } + + const apiKey = process.env.LIVEKIT_API_KEY; + const apiSecret = process.env.LIVEKIT_API_SECRET; + if (!apiKey || !apiSecret) return errorResponse('LiveKit not configured', 503); + + const body: unknown = await request.json().catch(() => ({})); + const { room, identity, name } = bodySchema.parse(body); + const roomName = partnerRoomName(partner, room); + + const token = new AccessToken(apiKey, apiSecret, { + // Namespaced so two partners' user ids can never collide in a room. + identity: `${partner.id}:${identity}`, + name, + ttl: '6h', + metadata: JSON.stringify({ partner: partner.id, e2ee: true }), + }); + const grant: VideoGrant = { + room: roomName, + roomJoin: true, + canPublish: true, + canSubscribe: true, + canPublishData: true, + canUpdateOwnMetadata: false, + }; + token.addGrant(grant); + + return successResponse({ + token: await token.toJwt(), + url: process.env.NEXT_PUBLIC_LIVEKIT_URL, + roomName, + maxParticipants: partner.maxParticipants, + iceServers: await getIceServers(), + e2ee: true, + }); + } catch (error) { + return handleApiError(error); + } +} diff --git a/apps/web/src/lib/partners.ts b/apps/web/src/lib/partners.ts new file mode 100644 index 0000000..3890fc6 --- /dev/null +++ b/apps/web/src/lib/partners.ts @@ -0,0 +1,73 @@ +/** + * Partner apps (e.g. qrypt.chat) that run end-to-end encrypted calls on + * PairUX's SFU through @profullstack/pairux-embed. + * + * A partner's SERVER holds a key `pux_pk_…` and calls POST /api/v1/partner/token + * to mint a LiveKit token for one of its users. PairUX never sees partner user + * accounts, and never sees the media: the partner shares the media key between + * its participants over its own encrypted channel. Rooms live in the partner's + * own namespace (`partner--`), which recording, restreaming and call + * analysis never touch (they only act on `session-*` rooms). + * + * Configuration, no database: PAIRUX_PARTNERS is JSON + * [{ "id": "qrypt", "name": "qrypt.chat", "keySha256": "", "maxParticipants": 16 }] + * Only the SHA-256 of a key is stored, so the env never holds a usable key. + */ +import { createHash, timingSafeEqual } from 'node:crypto'; + +export interface Partner { + id: string; + name: string; + keySha256: string; + maxParticipants: number; +} + +const ID_RE = /^[a-z0-9][a-z0-9-]{1,30}$/; + +export function partnersFromEnv(raw = process.env.PAIRUX_PARTNERS): Partner[] { + if (!raw) return []; + let list: unknown; + try { + list = JSON.parse(raw); + } catch { + return []; + } + if (!Array.isArray(list)) return []; + return list.flatMap((p: Partial) => + typeof p.id === 'string' && + ID_RE.test(p.id) && + typeof p.keySha256 === 'string' && + /^[0-9a-f]{64}$/.test(p.keySha256) + ? [ + { + id: p.id, + name: typeof p.name === 'string' ? p.name : p.id, + keySha256: p.keySha256, + maxParticipants: Number(p.maxParticipants) > 0 ? Number(p.maxParticipants) : 16, + }, + ] + : [] + ); +} + +export const hashPartnerKey = (key: string) => createHash('sha256').update(key).digest('hex'); + +/** The partner whose key is presented as `Authorization: Bearer pux_pk_…`, or null. */ +export function authenticatePartner( + request: Request, + partners = partnersFromEnv() +): Partner | null { + const match = /^Bearer\s+(pux_pk_[A-Za-z0-9_-]{20,})$/.exec( + request.headers.get('authorization') ?? '' + ); + if (!match?.[1]) return null; + const presented = Buffer.from(hashPartnerKey(match[1]), 'hex'); + for (const p of partners) { + const expected = Buffer.from(p.keySha256, 'hex'); + if (expected.length === presented.length && timingSafeEqual(expected, presented)) return p; + } + return null; +} + +/** The LiveKit room for a partner's room id: always inside its own namespace. */ +export const partnerRoomName = (partner: Partner, room: string) => `partner-${partner.id}-${room}`; diff --git a/packages/embed/README.md b/packages/embed/README.md new file mode 100644 index 0000000..878847f --- /dev/null +++ b/packages/embed/README.md @@ -0,0 +1,49 @@ +# @profullstack/pairux-embed + +End-to-end encrypted voice, video and screen sharing for any web app, on [PairUX](https://pairux.com)'s SFU. + +Your app supplies the **media key**: 32 random bytes that you share with the other participants over your own encrypted channel. Every audio and video frame is encrypted in the browser before it leaves, so PairUX's servers only ever forward ciphertext and can neither hear nor see the call. If a browser can't encrypt, the library refuses to join rather than falling back to a plaintext call. + +```sh +npm install @profullstack/pairux-embed +``` + +## 1. Your server gets a token + +Partner apps get a key (`pux_pk_…`) from PairUX. Keep it on your server and mint a token per user: + +```js +const res = await fetch('https://pairux.com/api/v1/partner/token', { + method: 'POST', + headers: { + Authorization: `Bearer ${process.env.PAIRUX_PARTNER_KEY}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ room: conversationId, identity: userId, name: displayName }), +}); +const { data } = await res.json(); // { token, url, roomName, iceServers, e2ee: true } +``` + +Rooms live in your own namespace. PairUX never records, restreams or analyses partner rooms. + +## 2. Your client joins, encrypted + +```js +import { PairuxCall, mountCall, newMediaKey } from '@profullstack/pairux-embed'; + +// Whoever starts the call makes the key and sends it to the others over +// YOUR end-to-end encrypted channel (qrypt.chat sends it ML-KEM-1024 encrypted). +const key = newMediaKey(); + +const call = new PairuxCall({ url: data.url, token: data.token, iceServers: data.iceServers, key }); +const ui = mountCall(document.getElementById('call'), call, { onLeave: () => ui.destroy() }); +await call.join({ audio: true, video: false }); +``` + +`mountCall` draws participant tiles and the mic, camera, screen and leave controls in plain DOM, with no framework. To theme it, set `--pxe-bg`, `--pxe-tile`, `--pxe-fg`, `--pxe-muted`, `--pxe-accent` and `--pxe-danger` on the container. To build your own UI instead, use `PairuxCall` directly. Its events are `participants`, `track`, `state` and `error`, and its methods are `setMic`, `setCamera`, `setScreen`, `rotateKey` and `leave`. + +**The E2EE worker:** by default the library loads `livekit-client/e2ee-worker` with `new Worker(new URL(...), import.meta.url)`, which Vite, webpack 5 and Next.js resolve. If your bundler doesn't, pass `worker`. + +## Licence + +MIT. diff --git a/packages/embed/package.json b/packages/embed/package.json new file mode 100644 index 0000000..7a9725f --- /dev/null +++ b/packages/embed/package.json @@ -0,0 +1,55 @@ +{ + "name": "@profullstack/pairux-embed", + "version": "0.1.0", + "description": "End-to-end encrypted voice, video and screen sharing for any web app, on PairUX's SFU. The host app supplies the media key; the server only forwards ciphertext.", + "license": "MIT", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./package.json": "./package.json" + }, + "files": [ + "dist", + "README.md" + ], + "keywords": [ + "webrtc", + "e2ee", + "end-to-end-encryption", + "video-call", + "voice-chat", + "screen-sharing", + "livekit", + "embed", + "pairux" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/profullstack/pairux.com.git", + "directory": "packages/embed" + }, + "scripts": { + "build": "tsc", + "dev": "tsc --watch", + "typecheck": "tsc --noEmit", + "lint": "eslint src/", + "test": "vitest run", + "clean": "rm -rf dist" + }, + "dependencies": { + "livekit-client": "^2.17.0" + }, + "devDependencies": { + "typescript": "^5.7.0", + "vitest": "^3.2.0", + "jsdom": "^25.0.0" + }, + "engines": { + "node": ">=18" + } +} diff --git a/packages/embed/src/call.test.ts b/packages/embed/src/call.test.ts new file mode 100644 index 0000000..098149a --- /dev/null +++ b/packages/embed/src/call.test.ts @@ -0,0 +1,128 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const lk = vi.hoisted(() => { + const order: string[] = []; + const handlers = new Map void>(); + const local = { + identity: 'me', + name: 'Me', + isSpeaking: false, + isMicrophoneEnabled: false, + isCameraEnabled: false, + isScreenShareEnabled: false, + setMicrophoneEnabled: vi.fn(async (on: boolean) => { + order.push(`mic:${String(on)}`); + local.isMicrophoneEnabled = on; + }), + setCameraEnabled: vi.fn(async (on: boolean) => { + local.isCameraEnabled = on; + }), + setScreenShareEnabled: vi.fn(async () => {}), + getTrackPublication: vi.fn(() => undefined), + }; + const room = { + localParticipant: local, + remoteParticipants: new Map(), + isE2EEEnabled: false, + on(ev: string, fn: (...a: unknown[]) => void) { + handlers.set(ev, fn); + return room; + }, + setE2EEEnabled: vi.fn(async (on: boolean) => { + order.push(`e2ee:${String(on)}`); + room.isE2EEEnabled = on; + }), + connect: vi.fn(async () => { + order.push('connect'); + }), + disconnect: vi.fn(async () => {}), + }; + return { + order, + handlers, + room, + local, + supported: { value: true }, + roomOptions: { value: undefined as unknown }, + }; +}); + +vi.mock('livekit-client', () => ({ + isE2EESupported: () => lk.supported.value, + ExternalE2EEKeyProvider: class { + setKey = vi.fn(async (k: ArrayBuffer) => { + lk.order.push(`key:${String(k.byteLength)}`); + }); + }, + // eslint-disable-next-line @typescript-eslint/no-extraneous-class -- stands in for livekit's Room constructor + Room: class { + constructor(opts: unknown) { + lk.roomOptions.value = opts; + return lk.room as never; + } + }, + RoomEvent: new Proxy({}, { get: (_t, p) => String(p) }), + Track: { Source: { Camera: 'camera', ScreenShare: 'screen_share' } }, +})); + +const { PairuxCall, E2EEUnsupportedError, newMediaKey } = await import('./call.js'); +const { mountCall } = await import('./widget.js'); + +const worker = {} as Worker; +const opts = { url: 'wss://sfu.pairux.com', token: 't', key: new Uint8Array(32).fill(7), worker }; + +beforeEach(() => { + lk.order.length = 0; + lk.supported.value = true; + lk.local.isMicrophoneEnabled = false; +}); + +describe('PairuxCall', () => { + it('refuses a browser that cannot encrypt rather than calling in plaintext', () => { + lk.supported.value = false; + expect(() => new PairuxCall(opts)).toThrow(E2EEUnsupportedError); + }); + + it('sets the key and turns encryption on before connecting, then publishes', async () => { + const call = new PairuxCall(opts); + expect((lk.roomOptions.value as { e2ee: { worker: Worker } }).e2ee.worker).toBe(worker); + await call.join({ audio: true }); + expect(lk.order).toEqual(['key:32', 'e2ee:true', 'connect', 'mic:true']); + expect(call.state).toBe('connected'); + expect(call.participants()[0]).toMatchObject({ + id: 'me', + isLocal: true, + micOn: true, + encrypted: true, + }); + }); + + it('rejects a short key', async () => { + const call = new PairuxCall({ ...opts, key: new Uint8Array(8) }); + await expect(call.join()).rejects.toThrow(/at least 32 bytes/); + expect(lk.room.connect).not.toHaveBeenCalledWith(expect.anything(), 'never'); + }); + + it('makes 32-byte random keys', () => { + const a = newMediaKey(); + expect(a).toHaveLength(32); + expect(Buffer.from(a).equals(Buffer.from(newMediaKey()))).toBe(false); + }); +}); + +describe('mountCall', () => { + it('renders a tile per participant and drives the call from its controls', async () => { + const call = new PairuxCall(opts); + await call.join({ audio: true }); + const el = document.createElement('div'); + const ui = mountCall(el, call); + expect(el.querySelectorAll('.pxe-tile')).toHaveLength(1); + expect(el.querySelector('.pxe-name')?.textContent).toContain('Me (you)'); + expect(el.querySelector('.pxe-status')?.textContent).toContain('End-to-end encrypted'); + const mute = [...el.querySelectorAll('button')].find((b) => b.textContent === 'Mute')!; + mute.click(); + await vi.waitFor(() => expect(lk.local.setMicrophoneEnabled).toHaveBeenLastCalledWith(false)); + ui.destroy(); + expect(el.children).toHaveLength(0); + }); +}); diff --git a/packages/embed/src/call.ts b/packages/embed/src/call.ts new file mode 100644 index 0000000..2c854e4 --- /dev/null +++ b/packages/embed/src/call.ts @@ -0,0 +1,249 @@ +/** + * An end-to-end encrypted call on PairUX's SFU. + * + * The host app (qrypt.chat, or anyone) supplies the media key: 32 random bytes + * it shares with the other participants over its own encrypted channel. Every + * audio and video frame is encrypted in the browser (LiveKit E2EE, insertable + * streams) before it leaves, so the SFU only ever forwards ciphertext and + * PairUX can neither hear nor see the call. A browser that cannot do E2EE is + * refused rather than downgraded to a plaintext call. + * + * const call = new PairuxCall({ url, token, iceServers, key }); + * call.addEventListener('participants', render); + * await call.join({ audio: true, video: false }); + */ +import { + ExternalE2EEKeyProvider, + isE2EESupported, + Room, + RoomEvent, + Track, + type Participant, + type RemoteTrack, +} from 'livekit-client'; + +export interface CallOptions { + /** LiveKit server URL (wss://…), from the partner token endpoint. */ + url: string; + /** LiveKit access token, from the partner token endpoint. */ + token: string; + iceServers?: RTCIceServer[]; + /** The media key: 32+ random bytes, shared only between participants. */ + key: Uint8Array | ArrayBuffer; + /** + * The LiveKit E2EE worker. Defaults to + * `new Worker(new URL('livekit-client/e2ee-worker', import.meta.url), { type: 'module' })`, + * which Vite, webpack 5 and Next.js resolve; pass your own if your bundler does not. + */ + worker?: Worker; +} + +export interface CallParticipant { + id: string; + name: string; + isLocal: boolean; + speaking: boolean; + micOn: boolean; + cameraOn: boolean; + screenOn: boolean; + /** Whether this participant's media is end-to-end encrypted (false = not yet / failed). */ + encrypted: boolean; +} + +export type CallState = 'idle' | 'connecting' | 'connected' | 'reconnecting' | 'ended'; + +/** Detail of the 'track' event: a remote track to play or show. */ +export interface CallTrack { + participantId: string; + kind: 'audio' | 'video' | 'unknown'; + source: string; + attach: (el: HTMLMediaElement) => void; + detach: () => void; +} + +const noop = (): void => undefined; + +export class E2EEUnsupportedError extends Error { + constructor() { + super( + 'This browser cannot end-to-end encrypt calls (no insertable streams). Use a current Chrome, Edge, Firefox or Safari.' + ); + this.name = 'E2EEUnsupportedError'; + } +} + +const toBuffer = (key: Uint8Array | ArrayBuffer): ArrayBuffer => { + const bytes = key instanceof Uint8Array ? key : new Uint8Array(key); + if (bytes.byteLength < 32) throw new Error('The media key must be at least 32 bytes.'); + return bytes.slice().buffer; +}; + +function defaultWorker(): Worker { + return new Worker(new URL('livekit-client/e2ee-worker', import.meta.url), { type: 'module' }); +} + +/** + * Events (CustomEvent): 'state' (detail: CallState), 'participants' + * (detail: CallParticipant[]), 'track' ({ participantId, kind, attach, detach }), + * 'error' (detail: Error). + */ +export class PairuxCall extends EventTarget { + readonly room: Room; + private keyProvider = new ExternalE2EEKeyProvider(); + private encryptedIds = new Set(); + private options: CallOptions; + state: CallState = 'idle'; + + constructor(options: CallOptions) { + super(); + if (!isE2EESupported()) throw new E2EEUnsupportedError(); + this.options = options; + this.room = new Room({ + adaptiveStream: true, + dynacast: true, + e2ee: { keyProvider: this.keyProvider, worker: options.worker ?? defaultWorker() }, + }); + this.wire(); + } + + private emit(type: string, detail?: unknown) { + this.dispatchEvent(new CustomEvent(type, { detail })); + } + + private setState(state: CallState) { + this.state = state; + this.emit('state', state); + } + + private wire() { + const r = this.room; + const changed = () => { + this.emit('participants', this.participants()); + }; + r.on(RoomEvent.ParticipantConnected, changed) + .on(RoomEvent.ParticipantDisconnected, changed) + .on(RoomEvent.TrackMuted, changed) + .on(RoomEvent.TrackUnmuted, changed) + .on(RoomEvent.LocalTrackPublished, changed) + .on(RoomEvent.LocalTrackUnpublished, changed) + .on(RoomEvent.ActiveSpeakersChanged, changed) + .on( + RoomEvent.ParticipantEncryptionStatusChanged, + (encrypted: boolean, participant?: Participant) => { + const id = participant?.identity ?? r.localParticipant.identity; + if (encrypted) this.encryptedIds.add(id); + else this.encryptedIds.delete(id); + changed(); + } + ) + .on(RoomEvent.EncryptionError, (error: Error) => { + this.emit('error', error); + }) + .on(RoomEvent.TrackSubscribed, (track: RemoteTrack, _pub, participant) => { + const detail: CallTrack = { + participantId: participant.identity, + kind: + track.kind === Track.Kind.Audio + ? 'audio' + : track.kind === Track.Kind.Video + ? 'video' + : 'unknown', + source: track.source, + attach: (el) => { + track.attach(el); + }, + detach: () => { + track.detach(); + }, + }; + this.emit('track', detail); + changed(); + }) + .on(RoomEvent.TrackUnsubscribed, changed) + .on(RoomEvent.Reconnecting, () => { + this.setState('reconnecting'); + }) + .on(RoomEvent.Reconnected, () => { + this.setState('connected'); + }) + .on(RoomEvent.Disconnected, () => { + this.setState('ended'); + }); + } + + /** Connect with encryption on before a single frame is sent. */ + async join({ audio = true, video = false }: { audio?: boolean; video?: boolean } = {}) { + this.setState('connecting'); + await this.keyProvider.setKey(toBuffer(this.options.key)); + await this.room.setE2EEEnabled(true); + await this.room.connect( + this.options.url, + this.options.token, + this.options.iceServers?.length ? { rtcConfig: { iceServers: this.options.iceServers } } : {} + ); + this.setState('connected'); + if (audio) await this.room.localParticipant.setMicrophoneEnabled(true); + if (video) await this.room.localParticipant.setCameraEnabled(true); + this.emit('participants', this.participants()); + } + + /** Rotate to a new media key (e.g. when someone leaves the conversation). */ + async rotateKey(key: Uint8Array | ArrayBuffer) { + this.options = { ...this.options, key }; + await this.keyProvider.setKey(toBuffer(key)); + } + + async setMic(on: boolean) { + await this.room.localParticipant.setMicrophoneEnabled(on); + } + + async setCamera(on: boolean) { + await this.room.localParticipant.setCameraEnabled(on); + } + + async setScreen(on: boolean) { + await this.room.localParticipant.setScreenShareEnabled(on, { audio: true }); + } + + async leave() { + await this.room.disconnect(); + this.setState('ended'); + } + + /** Everyone in the call, local participant first. */ + participants(): CallParticipant[] { + const r = this.room; + const all: Participant[] = [r.localParticipant, ...r.remoteParticipants.values()]; + return all.map((p) => ({ + id: p.identity, + name: p.name ?? p.identity, + isLocal: p === r.localParticipant, + speaking: p.isSpeaking, + micOn: p.isMicrophoneEnabled, + cameraOn: p.isCameraEnabled, + screenOn: p.isScreenShareEnabled, + encrypted: this.encryptedIds.has(p.identity) || (p === r.localParticipant && r.isE2EEEnabled), + })); + } + + /** Attach a participant's camera (or screen) video to an element; returns a detach function. */ + attachVideo(participantId: string, el: HTMLVideoElement, source: 'camera' | 'screen' = 'camera') { + const r = this.room; + const p = + participantId === r.localParticipant.identity + ? r.localParticipant + : r.remoteParticipants.get(participantId); + const pub = p?.getTrackPublication( + source === 'screen' ? Track.Source.ScreenShare : Track.Source.Camera + ); + const track = pub?.track; + if (!track) return noop; + track.attach(el); + return () => track.detach(el); + } +} + +/** A fresh 32-byte media key, for the host app to share over its own encrypted channel. */ +export function newMediaKey(): Uint8Array { + return crypto.getRandomValues(new Uint8Array(32)); +} diff --git a/packages/embed/src/index.ts b/packages/embed/src/index.ts new file mode 100644 index 0000000..ada1778 --- /dev/null +++ b/packages/embed/src/index.ts @@ -0,0 +1,4 @@ +export { PairuxCall, E2EEUnsupportedError, newMediaKey } from './call.js'; +export type { CallOptions, CallParticipant, CallState } from './call.js'; +export { mountCall } from './widget.js'; +export type { WidgetOptions } from './widget.js'; diff --git a/packages/embed/src/widget.ts b/packages/embed/src/widget.ts new file mode 100644 index 0000000..45a1c44 --- /dev/null +++ b/packages/embed/src/widget.ts @@ -0,0 +1,200 @@ +/** + * A drop-in call UI for a PairuxCall: participant tiles and controls, plain DOM + * (no framework), themeable through CSS custom properties on the container: + * --pxe-bg, --pxe-tile, --pxe-fg, --pxe-muted, --pxe-accent, --pxe-danger. + * + * const ui = mountCall(document.getElementById('call'), call, { onLeave }); + * ... + * ui.destroy(); + */ +import type { CallParticipant, CallTrack, PairuxCall } from './call.js'; + +export interface WidgetOptions { + onLeave?: () => void; + /** Words on the controls, for translation. */ + labels?: Partial; +} + +const LABELS = { + mute: 'Mute', + unmute: 'Unmute', + cameraOn: 'Start video', + cameraOff: 'Stop video', + shareOn: 'Share screen', + shareOff: 'Stop sharing', + leave: 'Leave', + encrypted: 'End-to-end encrypted', + connecting: 'Connecting…', + reconnecting: 'Reconnecting…', +}; + +const CSS = ` +.pxe { display: flex; flex-direction: column; gap: .75rem; height: 100%; min-height: 240px; padding: .75rem; box-sizing: border-box; background: var(--pxe-bg, #0b0d12); color: var(--pxe-fg, #e6e8ee); font: 14px system-ui, sans-serif; border-radius: 12px; } +.pxe-status { font-size: 12px; color: var(--pxe-muted, #8a90a0); display: flex; gap: .5rem; align-items: center; } +.pxe-grid { flex: 1; display: grid; gap: .5rem; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); grid-auto-rows: minmax(120px, 1fr); } +.pxe-tile { position: relative; overflow: hidden; border-radius: 10px; background: var(--pxe-tile, #161a23); display: flex; align-items: center; justify-content: center; outline: 2px solid transparent; transition: outline-color .15s; } +.pxe-tile.speaking { outline-color: var(--pxe-accent, #3b82f6); } +.pxe-tile video { width: 100%; height: 100%; object-fit: cover; background: #000; } +.pxe-tile.screen video { object-fit: contain; } +.pxe-avatar { width: 64px; height: 64px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-weight: 600; font-size: 22px; background: var(--pxe-accent, #3b82f6); color: #fff; } +.pxe-name { position: absolute; left: 8px; bottom: 8px; padding: 2px 8px; border-radius: 6px; background: rgba(0,0,0,.55); font-size: 12px; display: flex; gap: 6px; align-items: center; } +.pxe-controls { display: flex; justify-content: center; gap: .5rem; flex-wrap: wrap; } +.pxe-btn { border: 1px solid rgba(255,255,255,.15); background: var(--pxe-tile, #161a23); color: inherit; padding: .5rem .9rem; border-radius: 999px; cursor: pointer; font: inherit; } +.pxe-btn.on { background: var(--pxe-accent, #3b82f6); border-color: transparent; color: #fff; } +.pxe-btn.leave { background: var(--pxe-danger, #dc2626); border-color: transparent; color: #fff; } +.pxe-btn:disabled { opacity: .5; cursor: not-allowed; } +`; + +function injectCss(doc: Document) { + if (doc.getElementById('pairux-embed-css')) return; + const style = doc.createElement('style'); + style.id = 'pairux-embed-css'; + style.textContent = CSS; + doc.head.appendChild(style); +} + +const initials = (name: string) => + name + .split(/\s+/) + .map((w) => w[0] ?? '') + .join('') + .slice(0, 2) + .toUpperCase() || '?'; + +export function mountCall(container: HTMLElement, call: PairuxCall, options: WidgetOptions = {}) { + const doc = container.ownerDocument; + const L = { ...LABELS, ...options.labels }; + injectCss(doc); + + const root = doc.createElement('div'); + root.className = 'pxe'; + const status = doc.createElement('div'); + status.className = 'pxe-status'; + const grid = doc.createElement('div'); + grid.className = 'pxe-grid'; + const controls = doc.createElement('div'); + controls.className = 'pxe-controls'; + const audioSink = doc.createElement('div'); + audioSink.hidden = true; + root.append(status, grid, controls, audioSink); + container.replaceChildren(root); + + const button = (label: string, onClick: () => Promise | void, extra = '') => { + const b = doc.createElement('button'); + b.type = 'button'; + b.className = `pxe-btn ${extra}`.trim(); + b.textContent = label; + b.addEventListener('click', () => { + void run(); + }); + const run = async () => { + b.disabled = true; + try { + await onClick(); + } finally { + b.disabled = false; + } + }; + return b; + }; + + let me: CallParticipant | undefined; + const micBtn = button(L.mute, () => call.setMic(!me?.micOn)); + const camBtn = button(L.cameraOn, () => call.setCamera(!me?.cameraOn)); + const shareBtn = button(L.shareOn, () => call.setScreen(!me?.screenOn)); + const leaveBtn = button( + L.leave, + async () => { + await call.leave(); + options.onLeave?.(); + }, + 'leave' + ); + controls.append(micBtn, camBtn, shareBtn, leaveBtn); + + const detachers = new Map void>(); + + function renderStatus() { + const everyoneEncrypted = me ? call.participants().every((p) => p.encrypted) : false; + status.textContent = + call.state === 'connecting' + ? L.connecting + : call.state === 'reconnecting' + ? L.reconnecting + : everyoneEncrypted + ? `🔒 ${L.encrypted}` + : ''; + } + + function render(list: CallParticipant[] = call.participants()) { + me = list.find((p) => p.isLocal); + micBtn.textContent = me?.micOn ? L.mute : L.unmute; + micBtn.classList.toggle('on', !!me?.micOn); + camBtn.textContent = me?.cameraOn ? L.cameraOff : L.cameraOn; + camBtn.classList.toggle('on', !!me?.cameraOn); + shareBtn.textContent = me?.screenOn ? L.shareOff : L.shareOn; + shareBtn.classList.toggle('on', !!me?.screenOn); + + for (const d of detachers.values()) d(); + detachers.clear(); + grid.replaceChildren( + ...list.map((p) => { + const tile = doc.createElement('div'); + tile.className = `pxe-tile${p.speaking ? ' speaking' : ''}${p.screenOn ? ' screen' : ''}`; + tile.dataset.participant = p.id; + if (p.screenOn || p.cameraOn) { + const video = doc.createElement('video'); + video.autoplay = true; + video.playsInline = true; + video.muted = true; // audio plays through the hidden sink, once + tile.append(video); + detachers.set(p.id, call.attachVideo(p.id, video, p.screenOn ? 'screen' : 'camera')); + } else { + const avatar = doc.createElement('div'); + avatar.className = 'pxe-avatar'; + avatar.textContent = initials(p.name); + tile.append(avatar); + } + const name = doc.createElement('div'); + name.className = 'pxe-name'; + name.textContent = `${p.encrypted ? '🔒 ' : ''}${p.name}${p.isLocal ? ' (you)' : ''}${p.micOn ? '' : ' 🔇'}`; + tile.append(name); + return tile; + }) + ); + renderStatus(); + } + + const onParticipants = (e: Event) => { + render((e as CustomEvent).detail); + }; + const onState = () => { + renderStatus(); + }; + const onTrack = (e: Event) => { + const t = (e as CustomEvent).detail; + if (t.kind !== 'audio') { + render(); + return; + } + const el = doc.createElement('audio'); + el.autoplay = true; + audioSink.append(el); + t.attach(el); + }; + call.addEventListener('participants', onParticipants); + call.addEventListener('state', onState); + call.addEventListener('track', onTrack); + render(); + + return { + root, + destroy() { + call.removeEventListener('participants', onParticipants); + call.removeEventListener('state', onState); + call.removeEventListener('track', onTrack); + for (const d of detachers.values()) d(); + container.replaceChildren(); + }, + }; +} diff --git a/packages/embed/tsconfig.json b/packages/embed/tsconfig.json new file mode 100644 index 0000000..1146690 --- /dev/null +++ b/packages/embed/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "declaration": true, + "declarationMap": true, + "noEmit": false, + "lib": ["ES2022", "DOM", "DOM.Iterable"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "src/**/*.test.ts"] +} diff --git a/packages/embed/vitest.config.ts b/packages/embed/vitest.config.ts new file mode 100644 index 0000000..28d8ef7 --- /dev/null +++ b/packages/embed/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'jsdom', + include: ['src/**/*.test.ts'], + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a86162a..218d722 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -488,6 +488,22 @@ importers: specifier: ^3.2.0 version: 3.2.6(@types/debug@4.1.13)(@types/node@24.10.9)(jsdom@25.0.1)(lightningcss@1.30.2)(terser@5.46.0) + packages/embed: + dependencies: + livekit-client: + specifier: ^2.17.0 + version: 2.17.0(@types/dom-mediacapture-record@1.0.22) + devDependencies: + jsdom: + specifier: ^25.0.0 + version: 25.0.1 + typescript: + specifier: ^5.7.0 + version: 5.9.3 + vitest: + specifier: ^3.2.0 + version: 3.2.6(@types/debug@4.1.13)(@types/node@24.10.9)(jsdom@25.0.1)(lightningcss@1.30.2)(terser@5.46.0) + packages/remote-input: dependencies: '@nut-tree-fork/nut-js':