Skip to content
Open
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
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,30 @@ If you replace the custom connection details endpoint, it must echo the requeste
`sessionId` and derive the same room name so dispatch and stop calls coordinate
with the connected room.

Integrated Generic configuration belongs to the LexVoice unified Mac startup authority.
Do not add integrated Generic settings to this repository's `.env.example` or `.env.local`.
The LexVoice lifecycle injects the server-only settings into this Next.js process.

The endpoint POSTs its fixed device ID, stable instance UUID, hostname, and
route-discovered private IPv4 to
`/api/endpoint/connectivity` every 10 seconds using the
`X-Endpoint-Connectivity-Token` header. The Next.js owner writes per-instance
mode-0600 records under a mode-0700 directory and expires them after 45 seconds.
One current instance may change address; zero, multiple, expired, malformed, or
out-of-CIDR leases fail closed. The registry is shared by same-host workers and
survives a Next.js restart; multi-host Next.js deployment is explicitly unsupported.
Generic has no static `EDGE_MEDIA_URL` authority: only server code may resolve
the current lease and it fixes the control target scheme, port, and paths.

For a Generic Start Call, the Agent joins first. The server then resolves one
active lease, reclaims stale endpoint state, and sends one authenticated start
request with a 15-minute `room_audio_input` token. Dispatch succeeds only after
the exact unmuted tracks are present: `room_audio_input/room_audio`,
`room_audio_input/room_video_raw`, and `room_video_input/room_video`. Stop Call
resolves the current lease, stops the processor and endpoint, cancels dispatch
work, and deletes the Room. The browser cannot supply a device target or either
server token.

### LiveAvatar Gateway Deployments

Sandbox-backed public deployments are owned by the LexVoice repository. Set
Expand Down
7 changes: 4 additions & 3 deletions app/api/connection-details/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,15 @@ type ConnectionDetails = {
const API_KEY = process.env.LIVEKIT_API_KEY;
const API_SECRET = process.env.LIVEKIT_API_SECRET;
const LIVEKIT_URL = process.env.LIVEKIT_URL;
const LIVEKIT_BROWSER_URL = process.env.LIVEKIT_BROWSER_URL?.trim() || LIVEKIT_URL?.trim();

// don't cache the results
export const revalidate = 0;

export async function POST(req: Request) {
try {
if (LIVEKIT_URL === undefined) {
throw new Error('LIVEKIT_URL is not defined');
if (LIVEKIT_BROWSER_URL === undefined) {
throw new Error('LIVEKIT_BROWSER_URL and LIVEKIT_URL are not defined');
}
if (API_KEY === undefined) {
throw new Error('LIVEKIT_API_KEY is not defined');
Expand Down Expand Up @@ -53,7 +54,7 @@ export async function POST(req: Request) {

// Return connection details
const data: ConnectionDetails = {
serverUrl: LIVEKIT_URL,
serverUrl: LIVEKIT_BROWSER_URL,
sessionId,
roomName,
participantToken: participantToken,
Expand Down
75 changes: 75 additions & 0 deletions app/api/endpoint/connectivity/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { NextResponse } from 'next/server';
import {
parseEndpointConnectivityPayload,
readConnectivityToken,
secretsMatch,
} from '@/lib/endpoint-connectivity';
import {
EndpointLeaseConflictError,
loadGenericEndpointLeaseConfig,
renewGenericEndpointLease,
} from '@/lib/generic-endpoint-lease';

export const runtime = 'nodejs';
export const revalidate = 0;

const NO_STORE_HEADERS = { 'Cache-Control': 'no-store' };

export async function POST(request: Request) {
const expectedToken = (process.env.ENDPOINT_CONNECTIVITY_TOKEN || '').trim();
const actualToken = readConnectivityToken(request);
if (!expectedToken) {
return NextResponse.json(
{ status: 'error', error: 'endpoint connectivity probe is not configured' },
{ status: 503, headers: NO_STORE_HEADERS }
);
}
if (!actualToken || !secretsMatch(actualToken, expectedToken)) {
return NextResponse.json(
{ status: 'error', error: 'unauthorized' },
{ status: 401, headers: NO_STORE_HEADERS }
);
}

let input: unknown;
try {
input = await request.json();
} catch {
return NextResponse.json(
{ status: 'error', error: 'valid JSON body is required' },
{ status: 400, headers: NO_STORE_HEADERS }
);
}

const parsed = parseEndpointConnectivityPayload(input);
if (!parsed.ok) {
return NextResponse.json(
{ status: 'error', error: parsed.error },
{ status: 400, headers: NO_STORE_HEADERS }
);
}

try {
const lease = await renewGenericEndpointLease(parsed.payload, loadGenericEndpointLeaseConfig());
console.info('Generic endpoint heartbeat accepted', {
deviceId: lease.deviceId,
instanceId: lease.instanceId,
address: lease.address,
receivedAt: lease.receivedAt,
expiresAt: lease.expiresAt,
});
return NextResponse.json(
{ status: 'leased', ...lease, hostname: parsed.payload.hostname },
{ headers: NO_STORE_HEADERS }
);
} catch (error) {
const conflict = error instanceof EndpointLeaseConflictError;
return NextResponse.json(
{
status: 'error',
error: conflict ? 'active endpoint instance conflict' : 'endpoint heartbeat rejected',
},
{ status: conflict ? 409 : 400, headers: NO_STORE_HEADERS }
);
}
}
15 changes: 7 additions & 8 deletions app/api/session/dispatch/route.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { NextResponse } from 'next/server';
import {
RoomSessionCancelledError,
dispatchRoomSession,
} from '@/app/api/session/session-dispatch-service';
formatSessionDispatchError,
runSessionDispatch,
} from '@/app/api/session/generic-session-dispatch';
import {
deriveLiveKitRoomName,
deriveSessionIdFromLiveKitRoomName,
Expand Down Expand Up @@ -52,14 +53,12 @@ export async function POST(req: Request) {
}

try {
const dispatch = await dispatchRoomSession({
const dispatch = await runSessionDispatch({
roomName,
sessionId,
agentName,
readiness: {
requireRoomVideoInputReady:
body.requireRoomVideoInputReady === true || body.require_room_video_input_ready === true,
},
requireRoomVideoInputReady:
body.requireRoomVideoInputReady === true || body.require_room_video_input_ready === true,
});
return NextResponse.json({ status: 'dispatched', roomName, agentName, sessionId, dispatch });
} catch (error) {
Expand All @@ -75,7 +74,7 @@ export async function POST(req: Request) {
roomName,
agentName,
sessionId,
error: error instanceof Error ? error.message : String(error),
error: formatSessionDispatchError(error),
},
{ status: 502 }
);
Expand Down
Loading
Loading