feat: harden support, localize knowledge and add operator availability [skip ci]
Source-only publication of reviewed commit 64e96a7f423673dc29ad56485be9fc79bce8af1c.
Passed in isolation: focused frontend/backend tests, race checks, TypeScript typecheck, lint, Go compilation, Flowgram build, and deterministic SDK build/tests.
Not accepted as a production release: Next.js export/native assembly remains blocked by environment memory limits; browser interaction and MySQL staging acceptance remain unverified. No production server, database/schema, runtime configuration, deployment, release tag, or workflow execution was performed.
Expected source tree: 0a4a7528e9.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
// Modified for targeted chat realtime recovery; see docs/fixes/chat-realtime-recovery.md.
|
||||
// Modified for hardening and realtime recovery; see docs/hardening/README.md and docs/fixes/chat-realtime-recovery.md.
|
||||
import { request } from "@/lib/api/client"
|
||||
import { translateCurrentMessage } from "@/i18n/messages"
|
||||
import { readSupportChatRuntimeConfig } from "@/lib/sdk/runtime-config"
|
||||
@@ -148,6 +148,8 @@ export type ImCustomerSessionExchangeResponse = {
|
||||
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"
|
||||
@@ -160,6 +162,18 @@ 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<ImCustomerSession>
|
||||
} | null = null
|
||||
let pendingSessionRefresh: {
|
||||
scope: string
|
||||
promise: Promise<ImCustomerSession>
|
||||
} | null = null
|
||||
|
||||
function buildGuestId() {
|
||||
return `guest_${generateUUID()}`
|
||||
@@ -209,6 +223,36 @@ function identityContextKey(config: RuntimeImConfig) {
|
||||
])
|
||||
}
|
||||
|
||||
// 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"))
|
||||
@@ -218,6 +262,12 @@ function requireEntryToken(config: RuntimeImConfig) {
|
||||
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 &&
|
||||
@@ -227,10 +277,8 @@ function matchesSessionIdentity(session: ImCustomerSession, config: RuntimeImCon
|
||||
)
|
||||
}
|
||||
const externalId = config.externalId || getGuestId()
|
||||
return (
|
||||
session.identityKey === `guest:${externalId}` ||
|
||||
(!!config.externalIdSignature && session.identityKey === `user:${externalId}`)
|
||||
)
|
||||
if (config.externalIdSignature) return session.identityKey === `user:${externalId}`
|
||||
return session.identityKey.startsWith("guest:") && session.guestContext === externalId
|
||||
}
|
||||
|
||||
function usableCustomerSession(session: ImCustomerSession | null, config: RuntimeImConfig) {
|
||||
@@ -243,7 +291,7 @@ function usableCustomerSession(session: ImCustomerSession | null, config: Runtim
|
||||
return true
|
||||
}
|
||||
|
||||
function sessionTokenClaims(token: string): { identityKey?: string; channelCode?: string } | null {
|
||||
function sessionTokenClaims(token: string): { identityKey?: string; channelCode?: string; identityScope?: string } | null {
|
||||
try {
|
||||
const payload = token.split(".")[1]
|
||||
if (!payload) return null
|
||||
@@ -276,7 +324,7 @@ function isCustomerSessionValid(
|
||||
if (identityKey && session.identityKey !== identityKey) {
|
||||
return false
|
||||
}
|
||||
return parseExpiresAt(session.expiresAt) > Date.now() + 5000
|
||||
return parseExpiresAt(session.expiresAt) > Date.now()
|
||||
}
|
||||
|
||||
export function readCustomerSession(): ImCustomerSession | null {
|
||||
@@ -321,12 +369,15 @@ export function applyCustomerSessionRefresh(payload?: {
|
||||
}
|
||||
const current = readCustomerSession()
|
||||
const config = getRuntimeImConfig()
|
||||
if (!current || !usableCustomerSession(current, config)) return
|
||||
// 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) return
|
||||
if (claims?.identityKey !== current.identityKey || claims?.channelCode !== current.channelId ||
|
||||
claims?.identityScope !== sessionTokenClaims(current.customerSessionToken)?.identityScope) return
|
||||
writeCustomerSession({
|
||||
...current,
|
||||
customerSessionToken: token,
|
||||
@@ -396,7 +447,7 @@ function createRequestOptions(
|
||||
} {
|
||||
const config = getRuntimeImConfig()
|
||||
requireEntryToken(config)
|
||||
const contextKey = identityContextKey(config)
|
||||
const identityScope = getCustomerIdentityScope()
|
||||
return {
|
||||
...init,
|
||||
skipAuth: true,
|
||||
@@ -405,7 +456,7 @@ function createRequestOptions(
|
||||
...(init?.headers as Record<string, string> | undefined),
|
||||
},
|
||||
onResponse: (response) => {
|
||||
if (contextKey === identityContextKey(getRuntimeImConfig())) {
|
||||
if (identityScope === getCustomerIdentityScope()) {
|
||||
applyCustomerSessionHeaders(response)
|
||||
}
|
||||
},
|
||||
@@ -429,7 +480,7 @@ function toQueryString(query?: Record<string, string | number | undefined>) {
|
||||
return output ? `?${output}` : ""
|
||||
}
|
||||
|
||||
export async function exchangeCustomerSession() {
|
||||
async function exchangeCustomerSessionForScope(identityScope: string) {
|
||||
const config = getRuntimeImConfig()
|
||||
requireEntryToken(config)
|
||||
const contextKey = identityContextKey(config)
|
||||
@@ -442,13 +493,14 @@ export async function exchangeCustomerSession() {
|
||||
headers: createExchangeHeaders(),
|
||||
}
|
||||
)
|
||||
if (contextKey !== identityContextKey(getRuntimeImConfig())) {
|
||||
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"))
|
||||
@@ -458,14 +510,66 @@ export async function exchangeCustomerSession() {
|
||||
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)) return cached!
|
||||
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<ImCustomerSessionExchangeResponse>("/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<ImConversationDetail>(`/api/conversation/${id}`, {
|
||||
...createRequestOptions(),
|
||||
|
||||
Reference in New Issue
Block a user