// Modified in the independent hardening fork; see docs/hardening/README.md. import { request } from "@/lib/api/client" import { translateCurrentMessage } from "@/i18n/messages" import { readSupportChatRuntimeConfig } from "@/lib/sdk/runtime-config" import { generateUUID, parseApiTimestamp } from "@/lib/utils" export type Paging = { page: number limit: number total: number } export type PageResult = { results?: T[] | null page: Paging cursor?: string hasMore?: boolean } export type ImConversationTag = { id: number name: string color: string } export type ImConversationParticipant = { id: number participantType: string participantId: number externalParticipantId?: string joinedAt?: string leftAt?: string status: number } export interface SubmitSatisfactionParams { conversationId: number score: number feedback?: string } export interface ConversationSatisfactionResponse { id: number conversationId: number score: number feedback: string createdAt: string } export type ImConversation = { id: number channelId: number customerName: string subject?: string status: number serviceMode: number priority: number currentAssigneeId: number currentAssigneeName?: string currentTeamId?: number lastMessageId: number lastMessageAt?: string lastActiveAt?: string lastMessageSummary?: string customerUnreadCount: number agentUnreadCount: number customerLastReadMessageId: number customerLastReadAt?: string agentLastReadMessageId: number agentLastReadAt?: string closedAt?: string tags?: ImConversationTag[] participants?: ImConversationParticipant[] satisfaction?: ConversationSatisfactionResponse | null } export type ImConversationDetail = ImConversation export type ConversationResponse = ImConversation export type ImMessage = { id: number conversationId: number workflowRunId?: number clientMsgId?: string senderType: string senderId: number senderName?: string senderAvatar?: string messageType: string content: string payload?: string sendStatus: number sentAt?: string deliveredAt?: string readAt?: string customerRead: boolean customerReadAt?: string agentRead: boolean agentReadAt?: string recalledAt?: string quotedMessageId?: number } export type ImAsset = { id: number assetId: string provider: string storageKey: string filename: string fileSize: number mimeType: string status: number url: string createdAt: string updatedAt: string createUserId: number createUserName: string updateUserId: number updateUserName: string } export type WidgetConfigResponse = { channelId?: string channelType?: string userToken?: string title?: string subtitle?: string themeColor?: string position?: "left" | "right" width?: string promptStarters?: string[] } export type ImWidgetConfig = WidgetConfigResponse export type ImCustomerSessionCustomer = { id: number name: string } export type ImCustomerSessionExchangeResponse = { customerSessionToken: string expiresAt: string identityKey: string customer: ImCustomerSessionCustomer } export type ImCustomerSession = ImCustomerSessionExchangeResponse & { channelId: string baseUrl: string /** Local binding only. Anonymous identity itself is assigned by the server. */ guestContext?: string } const GUEST_STORAGE_KEY = "cs_ai_agent_im_guest_id" const CUSTOMER_SESSION_STORAGE_KEY = "cs_ai_agent_customer_session" const CUSTOMER_SESSION_TOKEN_HEADER = "X-Customer-Session-Token" const CUSTOMER_SESSION_EXPIRES_HEADER = "X-Customer-Session-Expires-At" const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL?.trim() || "" const OPEN_IM_CHANNEL_ID = process.env.NEXT_PUBLIC_OPEN_IM_CHANNEL_ID?.trim() || "" let entryIdentityExchangeKey = "" let observedIdentityContext = "" let identityGeneration = 0 let observedSessionIdentity = "" let sessionIdentityGeneration = 0 let pendingSessionExchange: { scope: string promise: Promise } | null = null let pendingSessionRefresh: { scope: string promise: Promise } | null = null function buildGuestId() { return `guest_${generateUUID()}` } export function getGuestId() { if (typeof window === "undefined") { return "" } const existing = window.localStorage.getItem(GUEST_STORAGE_KEY)?.trim() if (existing) { return existing } const guestId = buildGuestId() window.localStorage.setItem(GUEST_STORAGE_KEY, guestId) return guestId } function getRuntimeImConfig() { const widgetConfig = readSupportChatRuntimeConfig() const baseUrl = (widgetConfig.apiBaseUrl || widgetConfig.baseUrl || API_BASE_URL) .trim() .replace(/\/$/, "") return { baseUrl, channelId: widgetConfig.channelId || OPEN_IM_CHANNEL_ID, externalId: (widgetConfig.externalId || "").trim(), externalIdSignature: (widgetConfig.externalIdSignature || "").trim(), externalIdSignedAt: Number(widgetConfig.externalIdSignedAt) || 0, externalName: (widgetConfig.externalName || "").trim(), userToken: (widgetConfig.userToken || "").trim(), requireUserToken: widgetConfig.requireUserToken === true, } } type RuntimeImConfig = ReturnType function identityContextKey(config: RuntimeImConfig) { return JSON.stringify([ config.baseUrl, config.channelId, config.externalId, config.userToken, config.externalIdSignature, config.externalIdSignedAt, config.requireUserToken, ]) } // An opaque, non-secret scope for in-memory drafts and request fencing. Token // renewal does not change it, but a new entry identity/channel/server does. export function getCustomerEntryScope() { const config = getRuntimeImConfig() const context = JSON.stringify([ identityContextKey(config), config.externalId || config.userToken ? "" : getGuestId(), ]) if (context !== observedIdentityContext) { observedIdentityContext = context identityGeneration += 1 } return `customer-${identityGeneration}` } export function getCustomerIdentityScope() { const entry = getCustomerEntryScope() const session = readCustomerSession() const config = getRuntimeImConfig() const identity = JSON.stringify([ entry, session && matchesSessionIdentity(session, config) ? session.identityKey : "", ]) if (identity !== observedSessionIdentity) { observedSessionIdentity = identity sessionIdentityGeneration += 1 } return `${entry}-${sessionIdentityGeneration}` } function requireEntryToken(config: RuntimeImConfig) { if (config.requireUserToken && !config.userToken) { throw new Error(translateCurrentMessage("api.supportAuthenticationRequired")) } } function matchesSessionIdentity(session: ImCustomerSession, config: RuntimeImConfig) { if (typeof session.identityKey !== "string") return false if (session.baseUrl !== config.baseUrl || session.channelId !== config.channelId) return false const claims = sessionTokenClaims(session.customerSessionToken) const identityMode = config.userToken ? "jwt" : config.externalIdSignature ? "hmac" : "guest" // Legacy tokens predate channel/auth-mode isolation and must be exchanged. // This is only cache validation; the server verifies every token signature. if (claims?.identityKey !== session.identityKey || claims.channelCode !== config.channelId || typeof claims.identityScope !== "string" || !claims.identityScope.startsWith(`v1:${identityMode}:`)) return false if (config.userToken || config.requireUserToken) { return ( !!config.userToken && (config.externalId ? session.identityKey === `user:${config.externalId}` : session.identityKey.startsWith("user:")) ) } const externalId = config.externalId || getGuestId() if (config.externalIdSignature) return session.identityKey === `user:${externalId}` return session.identityKey.startsWith("guest:") && session.guestContext === externalId } function usableCustomerSession(session: ImCustomerSession | null, config: RuntimeImConfig) { if (!session || !isCustomerSessionValid(session) || !matchesSessionIdentity(session, config)) return false // A prior iframe's session, especially a prior guest, cannot replace this // entry's JWT verification. Signed HMAC entries likewise re-exchange first. if ((config.userToken || config.externalIdSignature) && entryIdentityExchangeKey !== identityContextKey(config)) { return false } return true } function sessionTokenClaims(token: string): { identityKey?: string; channelCode?: string; identityScope?: string } | null { try { const payload = token.split(".")[1] if (!payload) return null const base64 = payload.replace(/-/g, "+").replace(/_/g, "/") const bytes = Uint8Array.from( atob(base64.padEnd(Math.ceil(base64.length / 4) * 4, "=")), (c) => c.charCodeAt(0) ) return JSON.parse(new TextDecoder().decode(bytes)) } catch { return null } } function parseExpiresAt(value: string) { return parseApiTimestamp(value) } function isCustomerSessionValid( session: ImCustomerSession | null, channelId?: string, identityKey?: string ) { if (!session?.customerSessionToken || !session.expiresAt) { return false } if (channelId && session.channelId !== channelId) { return false } if (identityKey && session.identityKey !== identityKey) { return false } return parseExpiresAt(session.expiresAt) > Date.now() } export function readCustomerSession(): ImCustomerSession | null { if (typeof window === "undefined") { return null } const raw = window.sessionStorage.getItem(CUSTOMER_SESSION_STORAGE_KEY) if (!raw) { return null } try { return JSON.parse(raw) as ImCustomerSession } catch { window.sessionStorage.removeItem(CUSTOMER_SESSION_STORAGE_KEY) return null } } function writeCustomerSession(session: ImCustomerSession) { if (typeof window === "undefined") { return } window.sessionStorage.setItem(CUSTOMER_SESSION_STORAGE_KEY, JSON.stringify(session)) } export function getCustomerSessionToken() { const config = getRuntimeImConfig() const session = readCustomerSession() return usableCustomerSession(session, config) ? session?.customerSessionToken ?? "" : "" } export function applyCustomerSessionRefresh(payload?: { customerSessionToken?: string expiresAt?: string }) { const token = payload?.customerSessionToken?.trim() const expiresAt = payload?.expiresAt?.trim() if (!token || !expiresAt) { return } const current = readCustomerSession() const config = getRuntimeImConfig() // The response may arrive during the expiry safety window. It is still a // valid renewal of the same server-authorized identity, not a new identity. if (!current || !matchesSessionIdentity(current, config)) return // Decode only for cache consistency, never as proof of authenticity. The // server still verifies signatures. A late old-account websocket/HTTP refresh // must not overwrite the current account's stored customer session. const claims = sessionTokenClaims(token) if (claims?.identityKey !== current.identityKey || claims?.channelCode !== current.channelId || claims?.identityScope !== sessionTokenClaims(current.customerSessionToken)?.identityScope) return writeCustomerSession({ ...current, customerSessionToken: token, expiresAt, }) } function applyCustomerSessionHeaders(response: Response) { applyCustomerSessionRefresh({ customerSessionToken: response.headers.get(CUSTOMER_SESSION_TOKEN_HEADER) ?? "", expiresAt: response.headers.get(CUSTOMER_SESSION_EXPIRES_HEADER) ?? "", }) } function createChannelHeaders() { const config = getRuntimeImConfig() return { "X-Channel-Id": config.channelId, } } function createExchangeHeaders() { const config = getRuntimeImConfig() requireEntryToken(config) const headers: Record = { "X-Channel-Id": config.channelId, } if (config.userToken) { headers.Authorization = `Bearer ${config.userToken}` } else { headers["X-External-Id"] = config.externalId || getGuestId() // Only meaningful together with a host-supplied externalId: the browser-local // guest id cannot be signed by the host. Without a valid signature the // server treats the visitor as anonymous. if (config.externalId && config.externalIdSignature && config.externalIdSignedAt) { headers["X-External-Id-Signature"] = config.externalIdSignature headers["X-External-Id-Signed-At"] = String(config.externalIdSignedAt) } if (config.externalName) { headers["X-External-Name"] = encodeURIComponent(config.externalName) } } return { ...headers, } } function createImHeaders(explicitToken?: string) { const sessionToken = explicitToken || getCustomerSessionToken() if (!sessionToken) { throw new Error(translateCurrentMessage("api.customerSessionNotReady")) } return { ...createChannelHeaders(), Authorization: `Bearer ${sessionToken}`, ...(explicitToken ? { "X-Customer-Session-Token": explicitToken } : {}), } } function createRequestOptions( init?: RequestInit, explicitToken?: string ): RequestInit & { baseUrl?: string skipAuth?: boolean onResponse?: (response: Response) => void } { const config = getRuntimeImConfig() requireEntryToken(config) const identityScope = getCustomerIdentityScope() return { ...init, skipAuth: true, headers: { ...createImHeaders(explicitToken), ...(init?.headers as Record | undefined), }, onResponse: (response) => { if (identityScope === getCustomerIdentityScope()) { applyCustomerSessionHeaders(response) } }, baseUrl: config.baseUrl, } } function toQueryString(query?: Record) { if (!query) { return "" } const params = new URLSearchParams() Object.entries(query).forEach(([key, value]) => { if (value === undefined || value === "") { return } params.set(key, String(value)) }) const output = params.toString() return output ? `?${output}` : "" } async function exchangeCustomerSessionForScope(identityScope: string) { const config = getRuntimeImConfig() requireEntryToken(config) const contextKey = identityContextKey(config) const result = await request( "/api/customer/session_exchange", { method: "POST", skipAuth: true, baseUrl: config.baseUrl, headers: createExchangeHeaders(), } ) if (identityScope !== getCustomerEntryScope()) { throw new Error(translateCurrentMessage("api.supportAuthenticationRequired")) } const session: ImCustomerSession = { ...result, channelId: config.channelId, baseUrl: config.baseUrl, ...(!config.userToken && !config.externalIdSignature ? { guestContext: config.externalId || getGuestId() } : {}), } if (!isCustomerSessionValid(session) || !matchesSessionIdentity(session, config)) { throw new Error(translateCurrentMessage("api.supportAuthenticationRequired")) } writeCustomerSession(session) entryIdentityExchangeKey = contextKey return session } export function exchangeCustomerSession() { const scope = getCustomerEntryScope() if (pendingSessionExchange?.scope === scope) { return pendingSessionExchange.promise } const promise = exchangeCustomerSessionForScope(scope) pendingSessionExchange = { scope, promise } void promise.then( () => { if (pendingSessionExchange?.promise === promise) pendingSessionExchange = null }, () => { if (pendingSessionExchange?.promise === promise) pendingSessionExchange = null } ) return promise } export async function ensureCustomerSession() { const config = getRuntimeImConfig() requireEntryToken(config) const cached = readCustomerSession() if (usableCustomerSession(cached, config)) { if (parseExpiresAt(cached!.expiresAt) <= Date.now() + 30000) { return refreshCustomerSession(cached!) } return cached! } return exchangeCustomerSession() } function refreshCustomerSession(cached: ImCustomerSession) { const scope = getCustomerIdentityScope() if (pendingSessionRefresh?.scope === scope) return pendingSessionRefresh.promise const refresh = async () => { // Refresh with proof of the existing identity. An unsigned exchange would // intentionally create a different guest and abandon the current history. const result = await request("/api/customer/session_refresh", { ...createRequestOptions({ method: "POST" }), }) if (scope !== getCustomerIdentityScope()) { throw new Error(translateCurrentMessage("api.supportAuthenticationRequired")) } const renewed: ImCustomerSession = { ...cached, ...result } if (!isCustomerSessionValid(renewed) || !matchesSessionIdentity(renewed, getRuntimeImConfig()) || renewed.identityKey !== cached.identityKey || renewed.customer.id !== cached.customer.id) { throw new Error(translateCurrentMessage("api.supportAuthenticationRequired")) } writeCustomerSession(renewed) return renewed } const promise = refresh() pendingSessionRefresh = { scope, promise } const clear = () => { if (pendingSessionRefresh?.promise === promise) pendingSessionRefresh = null } void promise.then(clear, clear) return promise } export function fetchImConversationDetail(id: number) { return request(`/api/conversation/${id}`, { ...createRequestOptions(), }) } export function fetchImMessages( query?: Record ) { return request>( `/api/message/list${toQueryString(query)}`, createRequestOptions() ) } export function createOrMatchImConversation() { return request("/api/conversation/create_or_match", { ...createRequestOptions({ method: "POST" }), }) } // Starts a new request instead of rejoining the unfinished one. The server caps // how many open requests one visitor may have and answers with a localized // message when the cap is reached. export function createImConversation(subject?: string) { return request("/api/conversation/create", { ...createRequestOptions({ method: "POST" }), body: JSON.stringify({ subject: subject?.trim() ?? "" }), }) } // The visitor's own requests, newest activity first. Scoping comes from the // external identity the request already carries. export function fetchImConversationList( query?: Record ) { return request>( `/api/conversation/list${toQueryString(query)}`, createRequestOptions() ) } export function fetchImWidgetConfig() { return request( `/api/channel/config${toQueryString({ channelId: getRuntimeImConfig().channelId, })}`, { skipAuth: true, baseUrl: getRuntimeImConfig().baseUrl, headers: createChannelHeaders(), } ) } export function closeImConversation(conversationId: number) { return request("/api/conversation/close", { ...createRequestOptions({ method: "POST", body: JSON.stringify({ conversationId }), }), }) } export function sendImMessage(payload: { conversationId: number messageType: string content: string payload?: string clientMsgId?: string }) { return request("/api/message/send", { ...createRequestOptions({ method: "POST", body: JSON.stringify(payload), }), }) } export function markImMessageRead(conversationId: number, messageId = 0) { return request("/api/message/read", { ...createRequestOptions({ method: "POST", body: JSON.stringify({ conversationId, messageId }), }), }) } export function uploadImImage(conversationId: number, file: File) { const formData = new FormData() formData.set("conversationId", String(conversationId)) formData.set("file", file) return request("/api/message/upload_image", { ...createRequestOptions({ method: "POST", body: formData, }), }) } export function uploadImAttachment(conversationId: number, file: File) { const formData = new FormData() formData.set("conversationId", String(conversationId)) formData.set("file", file) return request("/api/message/upload_attachment", { ...createRequestOptions({ method: "POST", body: formData, }), }) } export async function submitConversationSatisfaction( params: SubmitSatisfactionParams, token?: string ): Promise { return request( "/api/conversation/satisfaction", createRequestOptions( { method: "POST", body: JSON.stringify(params), }, token ) ) }