@juspay/neurolink
Version:
The pipe layer of an AI nervous system: one interface connecting provider neurons to your application, across three inference types — generate, stream and decide. `decide` returns typed, calibrated judgements from a non-generative model (~400ms, ~$0.00002
153 lines (152 loc) • 6.34 kB
JavaScript
/**
* Signed memory binding for a voice call.
*
* A voice worker persists a call's turns to a conversation, so it has to know
* WHOSE conversation and WHICH one — and it must not take either from anything
* a participant can influence. The manager that creates the room (for example
* a `/start` endpoint) already holds the verified session, so it signs a small
* binding and writes it into the room metadata next to `authToken` and
* `mcpContext`:
*
* base64(JSON({ authToken, mcpContext, memory: { userId, sessionId, exp, sig } }))
*
* The worker verifies the signature with the same shared secret. The signature
* covers the room name, so a binding copied into another room does not verify.
* Room metadata is readable by participants, so this does not hide the ids; it
* stops anyone without the secret from choosing them.
*
* Everything here fails closed. A missing secret, a missing or malformed
* binding, a bad signature or an expired binding all yield `{ ok: false }`, and
* the caller carries on with the call, storing nothing.
*
* See docs/features/livekit-voice-agent.md.
*/
import { createHmac, timingSafeEqual } from "node:crypto";
import { Buffer } from "node:buffer";
import { z } from "zod";
/** Domain-separation label that starts every signed message. */
export const VOICE_MEMORY_BINDING_VERSION = "neurolink.voice.memory.v1";
/** Environment variable the worker reads the shared secret from when none is passed in. */
export const VOICE_MEMORY_SECRET_ENV = "NEUROLINK_VOICE_MEMORY_SECRET";
/** Longest expiry, counted from now, that a worker accepts (24 hours). */
export const VOICE_MEMORY_MAX_TTL_SECONDS = 24 * 60 * 60;
/** A secret shorter than this is treated as absent. */
const MIN_SECRET_LENGTH = 16;
const SESSION_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/;
// Whitespace and control characters are refused; ":" is allowed because the
// chat user id is itself composed with it (customer:merchant:shop).
// eslint-disable-next-line no-control-regex
const USER_ID_PATTERN = /^[^\s\u0000-\u001f\u007f]{1,256}$/u;
/** A conversation id a voice call may append to: letters, digits, "_" and "-", up to 128, no ":". */
export function isValidVoiceSessionId(value) {
return typeof value === "string" && SESSION_ID_PATTERN.test(value);
}
/** A user id a voice call may be bound to: 1 to 256 characters, no whitespace or control characters. */
export function isValidVoiceUserId(value) {
return typeof value === "string" && USER_ID_PATTERN.test(value);
}
function usableSecret(secret) {
return typeof secret === "string" && secret.length >= MIN_SECRET_LENGTH;
}
/**
* What is signed. A JSON array, not a delimiter-joined string, so no choice of
* field values can make two different bindings produce the same message.
*/
function signedMessage(room, userId, sessionId, exp) {
return JSON.stringify([
VOICE_MEMORY_BINDING_VERSION,
room,
userId,
sessionId,
exp,
]);
}
function signature(secret, message) {
return createHmac("sha256", secret).update(message).digest();
}
/**
* Sign a binding for a room. Throws on an unusable secret or invalid ids: the
* signer is the trusted side, and a binding it cannot sign correctly is a bug
* to surface there, not something to paper over.
*/
export function signVoiceMemoryBinding(input) {
const { room, userId, sessionId, secret } = input;
if (!room) {
throw new Error("A voice memory binding needs the room name");
}
if (!usableSecret(secret)) {
throw new Error(`The voice memory secret must be at least ${MIN_SECRET_LENGTH} characters`);
}
if (!isValidVoiceUserId(userId)) {
throw new Error("Invalid voice memory userId");
}
if (!isValidVoiceSessionId(sessionId)) {
throw new Error("Invalid voice memory sessionId");
}
const now = input.now ?? Math.floor(Date.now() / 1000);
const ttl = input.ttlSeconds ?? 2 * 60 * 60;
if (!Number.isInteger(ttl) ||
ttl <= 0 ||
ttl > VOICE_MEMORY_MAX_TTL_SECONDS) {
throw new Error(`ttlSeconds must be a whole number between 1 and ${VOICE_MEMORY_MAX_TTL_SECONDS}`);
}
const exp = now + ttl;
const sig = signature(secret, signedMessage(room, userId, sessionId, exp)).toString("base64url");
return { userId, sessionId, exp, sig };
}
const bindingSchema = z.object({
userId: z.string(),
sessionId: z.string(),
exp: z.number().int(),
sig: z.string(),
});
const roomMetadataSchema = z.object({ memory: z.unknown().optional() });
function decodeMetadata(encoded) {
try {
return JSON.parse(Buffer.from(encoded, "base64").toString("utf-8"));
}
catch {
return undefined;
}
}
/**
* Read and verify the memory binding in a room's metadata.
*
* Never throws and never logs ids. `reason` says why a binding was rejected so
* the caller can log it.
*/
export function verifyVoiceMemoryBinding(input) {
const { roomMetadata, roomName, secret } = input;
if (!usableSecret(secret)) {
return { ok: false, reason: "no-secret" };
}
if (!roomName || !roomMetadata) {
return { ok: false, reason: "no-binding" };
}
const metadata = roomMetadataSchema.safeParse(decodeMetadata(roomMetadata));
if (!metadata.success || metadata.data.memory === undefined) {
return { ok: false, reason: "no-binding" };
}
const parsed = bindingSchema.safeParse(metadata.data.memory);
if (!parsed.success) {
return { ok: false, reason: "malformed" };
}
const { userId, sessionId, exp, sig } = parsed.data;
if (!isValidVoiceUserId(userId) || !isValidVoiceSessionId(sessionId)) {
return { ok: false, reason: "malformed" };
}
const expected = signature(secret, signedMessage(roomName, userId, sessionId, exp));
const presented = Buffer.from(sig, "base64url");
if (presented.length !== expected.length ||
!timingSafeEqual(presented, expected)) {
return { ok: false, reason: "bad-signature" };
}
const now = input.now ?? Math.floor(Date.now() / 1000);
if (exp <= now) {
return { ok: false, reason: "expired" };
}
if (exp - now > VOICE_MEMORY_MAX_TTL_SECONDS) {
return { ok: false, reason: "expiry-too-far" };
}
return { ok: true, userId, sessionId, exp };
}