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,7 @@
// Modified in the independent hardening fork; see docs/hardening/README.md.
// Modified for AgentDeskFixes: prevent stale profile updates and logout from modifying a newer session.
import { clearSession, readSession, writeSession, type AuthSession } from "@/lib/auth"
import { isSameSession } from "@/lib/session-request-guard"
import { request } from "@/lib/api/client"
export type LoginRequest = {
@@ -41,17 +44,17 @@ export async function fetchProfile() {
}
export async function updateProfile(payload: UpdateProfileRequest) {
const stored = readSession()
const data = await request<AuthSession>("/api/auth/profile/update", {
method: "POST",
body: JSON.stringify(payload),
})
const stored = readSession()
const nextSession: AuthSession = {
...data,
accessToken: data.accessToken || stored?.accessToken || "",
expiresAt: data.expiresAt || stored?.expiresAt,
}
writeSession(nextSession)
if (isSameSession(stored, readSession())) writeSession(nextSession)
return nextSession
}
@@ -65,11 +68,12 @@ export function uploadProfileAvatar(file: File) {
}
export async function logout() {
const stored = readSession()
try {
await request("/api/auth/logout", {
method: "POST",
})
} finally {
clearSession()
if (isSameSession(stored, readSession())) clearSession()
}
}

View File

@@ -1,4 +1,7 @@
import { expireSession, readSession } from "@/lib/auth"
// Modified in the independent hardening fork; see docs/hardening/README.md.
// Modified for AgentDeskFixes: scope authentication expiry to the session that issued each request.
import { expireSession, readSession, type AuthSession } from "@/lib/auth"
import { isSameSession } from "@/lib/session-request-guard"
import { translateCurrentMessage } from "@/i18n/messages"
const API_BASE_URL =
@@ -50,10 +53,10 @@ function normalizeApiRequestError(error: unknown) {
)
}
async function parseResult<T>(response: Response) {
async function parseResult<T>(response: Response, requestSession: AuthSession | null) {
const payload = (await response.json()) as JsonResult<T>
if (!response.ok || !payload.success) {
if (payload.errorCode === 3000 || payload.errorCode === 3002) {
if ((payload.errorCode === 3000 || payload.errorCode === 3002) && isSameSession(requestSession, readSession())) {
expireSession()
}
throw new ApiRequestError(
@@ -65,11 +68,10 @@ async function parseResult<T>(response: Response) {
return payload.data
}
function buildRequestHeaders(headers: HeadersInit | undefined, skipAuth?: boolean, body?: BodyInit | null) {
const session = readSession()
function buildRequestHeaders(headers: HeadersInit | undefined, session: AuthSession | null, body?: BodyInit | null) {
const authHeaders = new Headers(headers)
if (!skipAuth && session?.accessToken) {
if (session?.accessToken) {
authHeaders.set("Authorization", `Bearer ${session.accessToken}`)
}
if (
@@ -101,7 +103,8 @@ export async function request<T>(
const { headers, skipAuth, baseUrl, onResponse, ...rest } = options
delete (rest as RequestOptions).baseUrl
delete (rest as RequestOptions).onResponse
const authHeaders = buildRequestHeaders(headers, skipAuth, rest.body)
const requestSession = skipAuth ? null : readSession()
const authHeaders = buildRequestHeaders(headers, requestSession, rest.body)
const requestBaseUrl = baseUrl !== undefined ? baseUrl : API_BASE_URL
try {
@@ -111,7 +114,7 @@ export async function request<T>(
cache: "no-store",
})
onResponse?.(response)
return await parseResult<T>(response)
return await parseResult<T>(response, requestSession)
} catch (error) {
throw normalizeApiRequestError(error)
}
@@ -124,7 +127,8 @@ export async function requestBlob(
const { headers, skipAuth, baseUrl, onResponse, ...rest } = options
delete (rest as RequestOptions).baseUrl
delete (rest as RequestOptions).onResponse
const authHeaders = buildRequestHeaders(headers, skipAuth, rest.body)
const requestSession = skipAuth ? null : readSession()
const authHeaders = buildRequestHeaders(headers, requestSession, rest.body)
const requestBaseUrl = baseUrl !== undefined ? baseUrl : API_BASE_URL
const response = await fetch(`${requestBaseUrl}${path}`, {
...rest,
@@ -136,7 +140,7 @@ export async function requestBlob(
const contentType = response.headers.get("Content-Type") ?? ""
if (contentType.includes("application/json")) {
try {
await parseResult<never>(response)
await parseResult<never>(response, requestSession)
} catch (error) {
throw error
}

View File

@@ -0,0 +1,173 @@
import assert from "node:assert/strict"
import { readFileSync } from "node:fs"
import test from "node:test"
import vm from "node:vm"
import ts from "typescript"
const storage = () => {
const values = new Map()
return { getItem: (key) => values.get(key) ?? null, setItem: (key, value) => values.set(key, value), removeItem: (key) => values.delete(key) }
}
const deferred = () => {
let resolve, reject
const promise = new Promise((yes, no) => { resolve = yes; reject = no })
return { promise, resolve, reject }
}
const token = (identityKey, channelCode = "support", mode = "guest", extra = {}) =>
`test.${Buffer.from(JSON.stringify({ identityKey, channelCode, identityScope: `v1:${mode}:test-channel-scope`, ...extra })).toString("base64url")}.not-a-real-signature`
const exchange = (identityKey = "guest:server-assigned", channelCode = "support", mode = "guest") => ({
identityKey, customerSessionToken: token(identityKey, channelCode, mode),
expiresAt: "2099-01-01T00:00:00Z", customer: { id: 1, name: "Visitor" },
})
function harness(request) {
let config = { baseUrl: "https://support.example", channelId: "support", externalId: "client-guest" }
const localStorage = storage(), sessionStorage = storage()
const source = readFileSync(new URL("./im.ts", import.meta.url), "utf8")
const compiled = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS } })
const moduleContext = { exports: {} }
const mocks = {
"@/lib/api/client": { request },
"@/i18n/messages": { translateCurrentMessage: (key) => key },
"@/lib/sdk/runtime-config": { readSupportChatRuntimeConfig: () => config },
"@/lib/utils": { generateUUID: () => "browser-local-guest", parseApiTimestamp: Date.parse },
}
vm.runInNewContext(compiled.outputText, { module: moduleContext, exports: moduleContext.exports,
require: (name) => { if (!mocks[name]) throw new Error(`Unexpected import ${name}`); return mocks[name] },
process: { env: {} }, window: { localStorage, sessionStorage }, atob, TextDecoder, URLSearchParams })
return { api: moduleContext.exports, sessionStorage, setConfig: (next) => { config = { ...config, ...next } } }
}
test("concurrent session ensures share one guest exchange and cache server-owned identity", async () => {
const pending = deferred()
let calls = 0
const h = harness(() => { calls++; return pending.promise })
const first = h.api.ensureCustomerSession(), second = h.api.ensureCustomerSession()
assert.equal(calls, 1)
pending.resolve(exchange())
const [left, right] = await Promise.all([first, second])
assert.equal(left.identityKey, "guest:server-assigned")
assert.equal(right.identityKey, left.identityKey)
assert.equal(left.guestContext, "client-guest")
await h.api.ensureCustomerSession()
assert.equal(calls, 1)
})
test("guest token cannot be reused under a different initiating visitor context", async () => {
let calls = 0
const h = harness(async () => exchange(`guest:server-${++calls}`))
await h.api.ensureCustomerSession()
h.setConfig({ externalId: "someone-else" })
assert.equal(h.api.getCustomerSessionToken(), "")
const next = await h.api.ensureCustomerSession()
assert.equal(calls, 2)
assert.equal(next.identityKey, "guest:server-2")
})
test("legacy unscoped tokens are re-exchanged instead of sent to protected endpoints", async () => {
let calls = 0
const h = harness(async () => { calls++; return exchange() })
h.sessionStorage.setItem("cs_ai_agent_customer_session", JSON.stringify({ ...exchange("guest:client-guest"),
customerSessionToken: token("guest:client-guest", "support", "guest", { identityScope: undefined }),
baseUrl: "https://support.example", channelId: "support", guestContext: "client-guest" }))
assert.equal(h.api.getCustomerSessionToken(), "")
await h.api.ensureCustomerSession()
assert.equal(calls, 1)
})
test("late session exchange from old identity cannot overwrite new session", async () => {
const old = deferred()
let calls = 0
const h = harness(() => ++calls === 1 ? old.promise : Promise.resolve(exchange("guest:new-server")))
const first = h.api.ensureCustomerSession()
h.setConfig({ externalId: "new-visitor" })
const next = await h.api.ensureCustomerSession()
old.resolve(exchange("guest:old-server"))
await assert.rejects(first, /supportAuthenticationRequired/)
assert.equal(h.api.readCustomerSession().identityKey, next.identityKey)
})
test("A to B to A entry changes invalidate old same-value exchange", async () => {
const old = deferred()
let calls = 0
const h = harness(() => ++calls === 1 ? old.promise : Promise.resolve(exchange("guest:new-A")))
const first = h.api.ensureCustomerSession()
h.setConfig({ externalId: "B" })
h.api.getCustomerIdentityScope()
h.setConfig({ externalId: "client-guest" })
await h.api.ensureCustomerSession()
old.resolve(exchange("guest:old-A"))
await assert.rejects(first, /supportAuthenticationRequired/)
assert.equal(h.api.readCustomerSession().identityKey, "guest:new-A")
})
test("customer token rotation preserves scope, but replacement guest identity changes it", async () => {
let calls = 0
const h = harness(async () => exchange(`guest:server-${++calls}`))
await h.api.ensureCustomerSession()
const scope = h.api.getCustomerIdentityScope()
h.api.applyCustomerSessionRefresh({ customerSessionToken: token("guest:server-1", "support", "guest", { revision: 2 }), expiresAt: "2099-02-01T00:00:00Z" })
assert.equal(h.api.getCustomerIdentityScope(), scope)
await h.api.exchangeCustomerSession()
assert.notEqual(h.api.getCustomerIdentityScope(), scope)
})
test("refresh with another auth mode or channel cannot poison cached session", async () => {
const h = harness(async () => exchange())
await h.api.ensureCustomerSession()
const original = h.api.getCustomerSessionToken()
h.api.applyCustomerSessionRefresh({ customerSessionToken: token("guest:server-assigned", "support", "jwt"), expiresAt: "2099-02-01T00:00:00Z" })
assert.equal(h.api.getCustomerSessionToken(), original)
h.api.applyCustomerSessionRefresh({ customerSessionToken: token("guest:server-assigned", "other"), expiresAt: "2099-02-01T00:00:00Z" })
assert.equal(h.api.getCustomerSessionToken(), original)
})
test("signed identity still requires exact user ID and correct auth mode", async () => {
const h = harness(async () => exchange("user:wrong", "support", "hmac"))
h.setConfig({ externalId: "expected", externalIdSignature: "test-not-a-real-signature", externalIdSignedAt: 1 })
await assert.rejects(h.api.ensureCustomerSession(), /supportAuthenticationRequired/)
assert.equal(h.api.getCustomerSessionToken(), "")
})
test("matching renewal arriving during old token expiry window keeps guest identity", async () => {
const h = harness(async () => exchange())
await h.api.ensureCustomerSession()
const cached = h.api.readCustomerSession()
h.sessionStorage.setItem("cs_ai_agent_customer_session", JSON.stringify({ ...cached, expiresAt: "2000-01-01T00:00:00Z" }))
const renewed = token(cached.identityKey, "support", "guest", { revision: 2 })
h.api.applyCustomerSessionRefresh({ customerSessionToken: renewed, expiresAt: "2099-02-01T00:00:00Z" })
assert.equal(h.api.getCustomerSessionToken(), renewed)
})
test("near-expiry guest renews its existing identity instead of exchanging a fresh guest", async () => {
const routes = []
const h = harness(async (route, options) => {
routes.push(route)
if (route.endsWith("session_refresh")) {
assert.equal(options.method, "POST")
assert.ok(options.headers.Authorization.startsWith("Bearer "))
}
return exchange("guest:original")
})
await h.api.ensureCustomerSession()
const cached = h.api.readCustomerSession()
h.sessionStorage.setItem("cs_ai_agent_customer_session", JSON.stringify({ ...cached, expiresAt: new Date(Date.now() + 4000).toISOString() }))
const resumed = await h.api.ensureCustomerSession()
assert.equal(resumed.identityKey, cached.identityKey)
assert.deepEqual(routes, ["/api/customer/session_exchange", "/api/customer/session_refresh"])
})
test("failed near-expiry refresh never falls back to a different anonymous guest", async () => {
let calls = 0
const h = harness(async (route) => {
calls++
if (route.endsWith("session_refresh")) throw new Error("offline")
return exchange("guest:original")
})
await h.api.ensureCustomerSession()
const cached = h.api.readCustomerSession()
h.sessionStorage.setItem("cs_ai_agent_customer_session", JSON.stringify({ ...cached, expiresAt: new Date(Date.now() + 4000).toISOString() }))
await assert.rejects(h.api.ensureCustomerSession(), /offline/)
assert.equal(calls, 2)
assert.equal(h.api.readCustomerSession().identityKey, "guest:original")
})

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

View File

@@ -0,0 +1,115 @@
import assert from "node:assert/strict"
import test from "node:test"
import { isSameSession } from "../session-request-guard.ts"
import { deferred, loadTypeScriptModule } from "../test-support/load-typescript-module.mjs"
function fixture() {
let session = { user: { id: 1 }, accessToken: "test-session-a" }
let expirations = 0
const response = deferred()
const client = loadTypeScriptModule(new URL("./client.ts", import.meta.url), {
"@/lib/auth": {
readSession: () => session,
expireSession: () => { session = null; expirations += 1 },
},
"@/lib/session-request-guard": { isSameSession },
"@/i18n/messages": { translateCurrentMessage: (key) => key },
}, { process: { env: {} }, Headers, FormData, fetch: () => response.promise })
return {
client, response,
get session() { return session },
set session(next) { session = next },
get expirations() { return expirations },
}
}
function expiredResponse() {
return new Response(JSON.stringify({ success: false, errorCode: 3002, message: "Expired" }), {
status: 401, headers: { "Content-Type": "application/json" },
})
}
for (const method of ["request", "requestBlob"]) {
test(`${method}: old auth failure cannot expire a newer login`, async () => {
const f = fixture()
const pending = f.client[method]("/test")
f.session = { user: { id: 2 }, accessToken: "test-session-b" }
f.response.resolve(expiredResponse())
await assert.rejects(pending, { errorCode: 3002 })
assert.equal(f.session.user.id, 2)
assert.equal(f.expirations, 0)
})
test(`${method}: matching auth failure expires only its own session`, async () => {
const f = fixture()
const pending = f.client[method]("/test")
f.response.resolve(expiredResponse())
await assert.rejects(pending, { errorCode: 3002 })
assert.equal(f.session, null)
assert.equal(f.expirations, 1)
})
test(`${method}: unauthenticated failures do not expire a dashboard session`, async () => {
const f = fixture()
const pending = f.client[method]("/test", { skipAuth: true })
f.response.resolve(expiredResponse())
await assert.rejects(pending, { errorCode: 3002 })
assert.equal(f.expirations, 0)
})
}
test("old logout and profile update cannot overwrite a new account", async () => {
for (const action of ["logout", "updateProfile"]) {
let session = { user: { id: 1 }, accessToken: "test-session-a" }
const response = deferred()
let writes = 0
const auth = loadTypeScriptModule(new URL("./auth.ts", import.meta.url), {
"@/lib/auth": {
readSession: () => session,
clearSession: () => { session = null; writes += 1 },
writeSession: (next) => { session = next; writes += 1 },
},
"@/lib/session-request-guard": { isSameSession },
"@/lib/api/client": { request: () => response.promise },
})
const pending = auth[action]({ nickname: "Updated" })
session = { user: { id: 2 }, accessToken: "test-session-b" }
response.resolve({ user: { id: 1 }, accessToken: "test-session-a" })
await pending
assert.equal(session.user.id, 2)
assert.equal(writes, 0)
}
})
for (const newerLogin of [true, false]) {
test(`logout navigation honors current session (newer login: ${newerLogin})`, async () => {
let session = null
const pendingLogout = deferred()
const redirects = []
const auth = loadTypeScriptModule(new URL("../../components/auth-provider.tsx", import.meta.url), {
react: {
createContext: () => ({ Provider: "context" }),
startTransition: (callback) => callback(),
useCallback: (callback) => callback,
useEffect: () => {},
},
"react/jsx-runtime": { jsx: (type, props) => ({ type, props }) },
"next/navigation": {
usePathname: () => "/dashboard",
useRouter: () => ({ replace: (url) => redirects.push(url) }),
},
"@/components/session-provider": {
SessionProvider: "session",
useSession: () => ({ session, ready: true, signOut: () => pendingLogout.promise }),
},
"@/lib/auth": { readSession: () => session },
})
const dashboardElement = auth.AuthProvider({ children: null }).props.children
const context = dashboardElement.type(dashboardElement.props)
const signOut = context.props.value.signOut()
if (newerLogin) session = { user: { id: 2 }, accessToken: "test-session-b" }
pendingLogout.resolve()
await signOut
assert.equal(redirects.length, newerLogin ? 0 : 1)
})
}

View File

@@ -0,0 +1,67 @@
import assert from "node:assert/strict"
import test from "node:test"
import ts from "typescript"
import { readFile } from "node:fs/promises"
import vm from "node:vm"
async function loadSetupAPI(request) {
const source = await readFile(new URL("./setup-readiness.ts", import.meta.url), "utf8")
const compiled = ts.transpileModule(source, {
compilerOptions: { target: ts.ScriptTarget.ES2017, module: ts.ModuleKind.CommonJS },
fileName: "setup-readiness.ts",
})
const sandbox = {
exports: {},
module: { exports: {} },
require(name) {
assert.equal(name, "@/lib/api/client")
return { request }
},
}
sandbox.exports = sandbox.module.exports
vm.runInNewContext(compiled.outputText, sandbox)
return sandbox.module.exports
}
test("simulation normalizes null or absent arrays from older servers", async () => {
for (const response of [{ candidate_operators: null, hops: null }, {}]) {
const api = await loadSetupAPI(async () => response)
const result = await api.simulateSetupRoute({ channel_id: 7 })
assert.equal(Array.isArray(result.candidate_operators), true)
assert.equal(result.candidate_operators.length, 0)
assert.equal(Array.isArray(result.hops), true)
assert.equal(result.hops.length, 0)
}
})
test("simulation preserves returned candidates", async () => {
const candidates = [{ user_id: 17, display_name: "Operator", eligible: true }]
const api = await loadSetupAPI(async () => ({ candidate_operators: candidates, hops: [] }))
const result = await api.simulateSetupRoute({ channel_id: 7 })
assert.equal(result.candidate_operators, candidates)
})
test("quick fix preview does not manufacture apply intent", async () => {
const calls = []
const api = await loadSetupAPI(async (url, options) => {
calls.push({ url, body: JSON.parse(options.body) })
return { applied: false, changes: [], preview_token: "reviewed" }
})
await api.applySetupQuickFix({ action: "fix_ai_agent_handoff", entity_id: 17 })
assert.equal(calls[0].url, "/api/dashboard/setup/quick_fix")
assert.equal(calls[0].body.entity_id, 17)
assert.equal("apply" in calls[0].body, false)
assert.equal("preview_token" in calls[0].body, false)
})
test("explicit apply carries the reviewed target and fingerprint", async () => {
let sent
const api = await loadSetupAPI(async (_url, options) => {
sent = JSON.parse(options.body)
return { applied: true, changes: [], applied_actions: [] }
})
await api.applySetupQuickFix({ action: "fix_ai_agent_handoff", entity_id: 17, apply: true, preview_token: "current-preview" })
assert.equal(sent.apply, true)
assert.equal(sent.entity_id, 17)
assert.equal(sent.preview_token, "current-preview")
})

View File

@@ -1,3 +1,4 @@
// Modified in AgentDeskFixes: safe, reviewed setup repairs and honest routing diagnostics.
import { request } from "@/lib/api/client"
export type SetupStepStatus = "ok" | "warning" | "error"
@@ -101,31 +102,51 @@ export type SetupSimulateRouteResponse = {
candidate_operators: SimulatedOperatorCandidate[]
}
export type SetupQuickFixChange = {
entity_type: string
entity_id: number
entity_name: string
operation: "create" | "update"
before: Record<string, unknown> | null
after: Record<string, unknown>
}
export type SetupQuickFixResponse = {
applied: boolean
preview_token: string
changes: SetupQuickFixChange[]
warning_codes: string[]
applied_actions: string[]
readiness: SetupReadinessResponse
readiness?: SetupReadinessResponse
}
export function fetchSetupReadiness() {
return request<SetupReadinessResponse>("/api/dashboard/setup/readiness")
}
export function simulateSetupRoute(payload: {
export async function simulateSetupRoute(payload: {
channel_id?: number
bypass_ai?: boolean
}) {
return request<SetupSimulateRouteResponse>(
const result = await request<SetupSimulateRouteResponse>(
"/api/dashboard/setup/simulate_route",
{
method: "POST",
body: JSON.stringify(payload),
},
)
return {
...result,
hops: result.hops ?? [],
candidate_operators: result.candidate_operators ?? [],
}
}
export function applySetupQuickFix(payload: {
action: SetupQuickFixAction
entity_id?: number
apply?: boolean
preview_token?: string
}) {
return request<SetupQuickFixResponse>("/api/dashboard/setup/quick_fix", {
method: "POST",

View File

@@ -0,0 +1,147 @@
import assert from "node:assert/strict"
import test from "node:test"
import { deferred, loadTypeScriptModule } from "../test-support/load-typescript-module.mjs"
const helpers = loadTypeScriptModule(new URL("../support-availability.ts", import.meta.url), {}, { Date, Intl, Set })
// Execute real API wrappers with offline transport and a deterministic deadline clock.
function fixture(request, initialConfig = {}) {
const timers = new Map()
let timerId = 0
let runtimeConfig = initialConfig
const api = loadTypeScriptModule(new URL("./support-availability.ts", import.meta.url), {
"@/lib/api/client": { request },
"@/lib/support-availability": helpers,
"@/lib/sdk/runtime-config": { readSupportChatRuntimeConfig: () => runtimeConfig },
}, {
AbortController,
process: { env: {} },
setTimeout(callback, delay) {
const id = ++timerId
timers.set(id, { callback, delay })
return id
},
clearTimeout(id) { timers.delete(id) },
})
return {
api,
timers,
setRuntimeConfig(value) { runtimeConfig = value },
}
}
test("calendar reads normalize nullable collections without changing the transport result", async () => {
const transportResult = {
teamId: 7, mode: "legacy", timezone: "", effectiveTimezone: "UTC", deploymentTimezone: "UTC",
weekly: null, exceptions: [{ date: "2026-12-31", closed: true, periods: null }],
}
const f = fixture(async () => transportResult)
const calendar = await f.api.fetchSupportCalendar(7)
assert.equal(calendar.weekly.length, 0)
assert.equal(calendar.exceptions[0].periods.length, 0)
assert.equal(transportResult.weekly, null)
assert.equal(transportResult.exceptions[0].periods, null)
assert.equal(f.timers.size, 0)
transportResult.exceptions = null
assert.equal((await f.api.fetchSupportCalendar(7)).exceptions.length, 0)
})
test("an already-aborted caller signal is not replaced with a live deadline signal", async () => {
const caller = new AbortController()
caller.abort()
const f = fixture(async (_path, options) => {
assert.equal(options.signal.aborted, true)
throw new Error("cancelled fixture request")
})
await assert.rejects(f.api.fetchMyAvailability(caller.signal), /cancelled fixture request/)
assert.equal(f.timers.size, 0)
})
test("the next availability read can recover after a deadline abort", async () => {
let calls = 0
const f = fixture((_path, options) => {
if (++calls > 1) return Promise.resolve({ userId: 42, eligible: false, reason: "offline" })
return new Promise((_, reject) => options.signal.addEventListener("abort", () => reject(new Error("fixture deadline")), { once: true }))
})
const first = f.api.fetchMyAvailability()
const deadline = [...f.timers.values()][0]
assert.equal(deadline.delay, 10000)
deadline.callback()
await assert.rejects(first, /fixture deadline/)
assert.equal(f.timers.size, 0)
const next = await f.api.fetchMyAvailability()
assert.equal(next.userId, 42)
assert.equal(next.eligible, false)
assert.equal(f.timers.size, 0)
})
test("concurrent availability reads own separate abort signals and deadlines", async () => {
const pending = []
const f = fixture((_path, options) => {
const response = deferred()
options.signal.addEventListener("abort", () => response.reject(new Error("fixture deadline")), { once: true })
pending.push({ ...response, signal: options.signal })
return response.promise
})
const first = f.api.fetchMyAvailability()
const second = f.api.fetchAvailabilityAgents(19)
assert.equal(f.timers.size, 2)
assert.notEqual(pending[0].signal, pending[1].signal)
;[...f.timers.values()][0].callback()
await assert.rejects(first, /fixture deadline/)
assert.equal(pending[1].signal.aborted, false)
pending[1].resolve([])
assert.equal((await second).length, 0)
assert.equal(f.timers.size, 0)
})
test("settled reads remove the caller's abort listener on both success and failure", async () => {
for (const fail of [false, true]) {
const listeners = new Set()
const signal = {
aborted: false,
addEventListener(event, listener) { assert.equal(event, "abort"); listeners.add(listener) },
removeEventListener(event, listener) { assert.equal(event, "abort"); assert.equal(listeners.delete(listener), true) },
}
const f = fixture(async () => {
assert.equal(listeners.size, 1)
if (fail) throw new Error("fixture transport failure")
return null
})
const pending = f.api.fetchMyAvailability(signal)
if (fail) await assert.rejects(pending, /fixture transport failure/)
else assert.equal(await pending, null)
assert.equal(listeners.size, 0)
assert.equal(f.timers.size, 0)
}
})
test("each public check reads the current runtime channel and endpoint rather than caching the prior identity", async () => {
const calls = []
const f = fixture(async (path, options) => {
calls.push({ path, options })
return { available: false, reason: "temporarily_absent", timezone: "UTC" }
}, { channelId: "channel-a", apiBaseUrl: "https://first.example.invalid/", baseUrl: "https://unused.example.invalid/" })
await f.api.fetchPublicSupportStatus()
f.setRuntimeConfig({ channelId: "channel-b", baseUrl: "https://second.example.invalid/" })
await f.api.fetchPublicSupportStatus()
assert.deepEqual(calls.map(({ options }) => options.baseUrl), ["https://first.example.invalid", "https://second.example.invalid"])
assert.deepEqual(calls.map(({ options }) => options.headers["X-Channel-Id"]), ["channel-a", "channel-b"])
for (const { path, options } of calls) {
assert.equal(path, "/api/support/status")
assert.equal(options.skipAuth, true)
assert.deepEqual(Object.keys(options.headers), ["X-Channel-Id"])
assert.equal(options.body, undefined)
}
})
test("successful public HTTP requests never manufacture human availability", async () => {
for (const reason of ["outside_hours", "temporarily_absent", "capacity"]) {
const response = { available: false, reason, timezone: "UTC", nextOpeningAt: "2026-12-31T09:00:00Z" }
const f = fixture(async () => response)
assert.equal(await f.api.fetchPublicSupportStatus(), response)
assert.equal(response.available, false)
assert.equal(f.timers.size, 0)
}
})

View File

@@ -0,0 +1,67 @@
// Availability is computed by the server; never infer presence from HTTP success.
import { supportCalendarPayload } from "@/lib/support-availability"
import { request } from "@/lib/api/client"
import type { ServiceStatus } from "@/lib/generated/enums"
import { readSupportChatRuntimeConfig } from "@/lib/sdk/runtime-config"
export type SupportPeriod = { start: string; end: string }
export type SupportCalendar = {
teamId: number
mode: "legacy" | "recurring"
timezone: string
effectiveTimezone: string
deploymentTimezone: string
weekly: (SupportPeriod & { weekday: number })[]
exceptions: { date: string; closed: boolean; periods: SupportPeriod[] }[]
}
export type AvailabilityReason = "eligible" | "profile_disabled" | "user_disabled" | "team_disabled" | "outside_hours" | "offline" | "busy" | "break" | "unavailable" | "vacation" | "auto_assign_disabled" | "capacity"
export type OperatorAvailability = {
userId: number
profileId: number
teamId: number
displayName?: string
serviceStatus: ServiceStatus
effectiveServiceStatus: ServiceStatus
returnServiceStatus?: ServiceStatus
absenceStartAt?: string
absenceEndAt?: string
online: boolean
eligible: boolean
reason: AvailabilityReason
calendar: SupportCalendar
calendarOpen: boolean
nextOpeningAt?: string
}
export type AvailabilityUpdate = { serviceStatus: ServiceStatus; absenceStartAt?: string; absenceEndAt?: string }
export type CalendarPreview = { open: boolean; timezone: string; nextOpeningAt?: string; mode: SupportCalendar["mode"] }
export type PublicSupportStatus = { available: boolean; reason: "available" | "outside_hours" | "temporarily_absent" | "capacity"; timezone: string; nextOpeningAt?: string }
const base = "/api/dashboard/support_availability"
// Bound background reads so one hung request cannot freeze polling forever.
async function availabilityRead<T>(path: string, options: Parameters<typeof request>[1] = {}) {
const controller = new AbortController()
const abort = () => controller.abort()
const source = options.signal
if (source?.aborted) controller.abort()
else source?.addEventListener("abort", abort, { once: true })
const deadline = setTimeout(abort, 10000)
try { return await request<T>(path, { ...options, signal: controller.signal }) }
finally { clearTimeout(deadline); source?.removeEventListener("abort", abort) }
}
export function fetchMyAvailability(signal?: AbortSignal) { return availabilityRead<OperatorAvailability | null>(`${base}/me`, { signal }) }
export function updateMyAvailability(payload: AvailabilityUpdate) { return request<OperatorAvailability>(`${base}/me`, { method: "POST", body: JSON.stringify(payload) }) }
export function fetchAvailabilityAgents(conversationId?: number, signal?: AbortSignal) { return availabilityRead<OperatorAvailability[]>(`${base}/agents${conversationId ? `?conversationId=${conversationId}` : ""}`, { signal }) }
export function updateAgentAvailability(userId: number, payload: AvailabilityUpdate) { return request<OperatorAvailability>(`${base}/agent`, { method: "POST", body: JSON.stringify({ ...payload, userId }) }) }
export async function fetchSupportCalendar(teamId: number) {
const data = await availabilityRead<SupportCalendar>(`${base}/calendar?teamId=${teamId}`)
return { ...data, weekly: data.weekly ?? [], exceptions: (data.exceptions ?? []).map(item => ({ ...item, periods: item.periods ?? [] })) }
}
export function updateSupportCalendar(calendar: SupportCalendar) { return request<SupportCalendar>(`${base}/calendar`, { method: "POST", body: JSON.stringify(supportCalendarPayload(calendar)) }) }
export function previewSupportCalendar(calendar: SupportCalendar, at?: string) { return availabilityRead<CalendarPreview>(`${base}/calendar/preview`, { method: "POST", body: JSON.stringify({ calendar: supportCalendarPayload(calendar), at }) }) }
export function fetchPublicSupportStatus(signal?: AbortSignal) {
const config = readSupportChatRuntimeConfig()
return availabilityRead<PublicSupportStatus>("/api/support/status", {
skipAuth: true, signal,
baseUrl: (config.apiBaseUrl || config.baseUrl || process.env.NEXT_PUBLIC_API_BASE_URL || "").trim().replace(/\/$/, ""),
headers: { "X-Channel-Id": config.channelId || process.env.NEXT_PUBLIC_OPEN_IM_CHANNEL_ID?.trim() || "" },
})
}