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:
2026-10-16 15:21:42 +00:00
parent 299ea50fb5
commit 2f7a491666
176 changed files with 11869 additions and 2716 deletions

View File

@@ -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(),