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,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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
173
web/lib/api/im-session.test.mjs
Normal file
173
web/lib/api/im-session.test.mjs
Normal 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")
|
||||
})
|
||||
@@ -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(),
|
||||
|
||||
115
web/lib/api/session-isolation.test.mjs
Normal file
115
web/lib/api/session-isolation.test.mjs
Normal 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)
|
||||
})
|
||||
}
|
||||
67
web/lib/api/setup-readiness.test.mjs
Normal file
67
web/lib/api/setup-readiness.test.mjs
Normal 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")
|
||||
})
|
||||
@@ -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",
|
||||
|
||||
147
web/lib/api/support-availability.transport.test.mjs
Normal file
147
web/lib/api/support-availability.transport.test.mjs
Normal 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)
|
||||
}
|
||||
})
|
||||
67
web/lib/api/support-availability.ts
Normal file
67
web/lib/api/support-availability.ts
Normal 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() || "" },
|
||||
})
|
||||
}
|
||||
@@ -337,10 +337,16 @@ export const KnowledgeRetrieveSceneLabels: Record<KnowledgeRetrieveScene, string
|
||||
export enum ServiceStatus {
|
||||
Idle = 0,
|
||||
Busy = 1,
|
||||
Break = 2,
|
||||
Unavailable = 3,
|
||||
Vacation = 4,
|
||||
}
|
||||
export const ServiceStatusLabels: Record<ServiceStatus, string> = {
|
||||
[ServiceStatus.Idle]: "空闲",
|
||||
[ServiceStatus.Busy]: "忙碌",
|
||||
[ServiceStatus.Break]: "休息",
|
||||
[ServiceStatus.Unavailable]: "不可用",
|
||||
[ServiceStatus.Vacation]: "休假",
|
||||
}
|
||||
|
||||
export enum Status {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Modified in the independent AgentDesk fork: localized knowledge workflows and safe display errors.
|
||||
import assert from "node:assert/strict"
|
||||
import test from "node:test"
|
||||
import ts from "typescript"
|
||||
@@ -54,3 +55,27 @@ test("localizes knowledge retrieve enum labels from stable values", async () =>
|
||||
assert.equal(getKnowledgeAnswerStatusLabel(2, "\u65e0\u7b54\u6848", t), "No answer")
|
||||
assert.equal(getKnowledgeChunkProviderLabel("semantic", t), "Semantic chunks")
|
||||
})
|
||||
|
||||
for (const locale of ["en-US", "ru-RU", "zh-CN"]) {
|
||||
test(`knowledge labels and safe errors use ${locale} catalog, not server names`, async () => {
|
||||
const helpers = await loadModule()
|
||||
const catalog = JSON.parse(await readFile(new URL(`../messages/${locale}.json`, import.meta.url), "utf8"))
|
||||
const translate = (key) => key.split(".").reduce((value, part) => value?.[part], catalog) ?? key
|
||||
for (const [code, key] of [["document", "typeDocument"], ["faq", "typeFAQ"]]) {
|
||||
assert.equal(helpers.getKnowledgeTypeLabel(code, translate), catalog.knowledge[key])
|
||||
}
|
||||
for (const [code, key] of [[0, "statusOk"], [1, "statusDisabled"], [2, "statusDeleted"]]) {
|
||||
assert.equal(helpers.getKnowledgeStatusLabel(code, translate), catalog.knowledge[key])
|
||||
}
|
||||
for (const [code, key] of [["pending", "indexPending"], ["indexed", "indexIndexed"], ["failed", "indexFailed"]]) {
|
||||
assert.equal(helpers.getKnowledgeIndexStatusLabel(code, translate), catalog.knowledge[key])
|
||||
}
|
||||
assert.equal(helpers.getKnowledgeStatusLabel(999, translate), catalog.knowledge.unknownStatus)
|
||||
assert.equal(helpers.getKnowledgeTypeLabel("future", translate), catalog.knowledge.unknownType)
|
||||
assert.equal(helpers.getKnowledgeIndexErrorLabel("SQL password=secret-example", translate), catalog.knowledge.indexFailureSummary)
|
||||
assert.equal(helpers.getKnowledgeIndexErrorLabel("", translate), "")
|
||||
for (const key of ["requestId", "rerank", "topScore", "promptTokens", "completionTokens", "traceData", "chunkNumber", "providerValue", "scoreValue", "rerankValue", "historyRecordedNotice"]) {
|
||||
assert.ok(catalog.knowledge[key], `missing ${locale} knowledge.${key}`)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Modified in the independent AgentDesk fork: localized knowledge workflows and safe display errors.
|
||||
type TFunction = (key: string, values?: Record<string, string | number>) => string
|
||||
|
||||
const CHANNEL_LABEL_KEYS: Record<string, string> = {
|
||||
@@ -46,3 +47,37 @@ export function getKnowledgeChunkProviderLabel(value: string, t: TFunction) {
|
||||
function translateByKey(key: string | undefined, fallback: string, t: TFunction) {
|
||||
return key ? t(key) : fallback
|
||||
}
|
||||
|
||||
const TYPE_LABEL_KEYS: Record<string, string> = {
|
||||
document: "knowledge.typeDocument",
|
||||
faq: "knowledge.typeFAQ",
|
||||
}
|
||||
|
||||
const STATUS_LABEL_KEYS: Record<number, string> = {
|
||||
0: "knowledge.statusOk",
|
||||
1: "knowledge.statusDisabled",
|
||||
2: "knowledge.statusDeleted",
|
||||
}
|
||||
|
||||
const INDEX_STATUS_LABEL_KEYS: Record<string, string> = {
|
||||
pending: "knowledge.indexPending",
|
||||
indexed: "knowledge.indexIndexed",
|
||||
failed: "knowledge.indexFailed",
|
||||
}
|
||||
|
||||
export function getKnowledgeTypeLabel(value: string, t: TFunction) {
|
||||
return translateByKey(TYPE_LABEL_KEYS[value], t("knowledge.unknownType"), t)
|
||||
}
|
||||
|
||||
export function getKnowledgeStatusLabel(value: number, t: TFunction) {
|
||||
return translateByKey(STATUS_LABEL_KEYS[value], t("knowledge.unknownStatus"), t)
|
||||
}
|
||||
|
||||
export function getKnowledgeIndexStatusLabel(value: string, t: TFunction) {
|
||||
return translateByKey(INDEX_STATUS_LABEL_KEYS[value], t("knowledge.unknownStatus"), t)
|
||||
}
|
||||
|
||||
// Never expose a stored provider/SQL error, even when talking to an older server.
|
||||
export function getKnowledgeIndexErrorLabel(error: string | undefined, t: TFunction) {
|
||||
return error ? t("knowledge.indexFailureSummary") : ""
|
||||
}
|
||||
|
||||
99
web/lib/message-editor-drafts.test.mjs
Normal file
99
web/lib/message-editor-drafts.test.mjs
Normal file
@@ -0,0 +1,99 @@
|
||||
import assert from "node:assert/strict"
|
||||
import test from "node:test"
|
||||
|
||||
import { createMessageEditorDraftStore } from "./message-editor-drafts.ts"
|
||||
|
||||
test("conversation drafts stay separate across A → B → A", () => {
|
||||
const store = createMessageEditorDraftStore()
|
||||
const a = store.get("1")
|
||||
store.update(a, "<p>For A</p>")
|
||||
const b = store.get("2")
|
||||
assert.equal(b.html, "")
|
||||
store.update(b, "<p>For B</p>")
|
||||
assert.equal(store.get("1").html, "<p>For A</p>")
|
||||
assert.equal(store.get("2").html, "<p>For B</p>")
|
||||
})
|
||||
|
||||
test("failed send preserves text and uploaded image metadata, and can retry", () => {
|
||||
const store = createMessageEditorDraftStore()
|
||||
const draft = store.get("1")
|
||||
store.update(draft, '<p>Message</p><img title="uploading-test">')
|
||||
draft.uploadedImages.set("uploading-test", { assetId: "test", provider: "local", storageKey: "test", url: "/test" })
|
||||
assert.equal(store.finishSend(store.beginSend("1"), false), false)
|
||||
assert.equal(draft.html, '<p>Message</p><img title="uploading-test">')
|
||||
assert.equal(draft.uploadedImages.size, 1)
|
||||
assert.ok(store.beginSend("1"))
|
||||
})
|
||||
|
||||
test("send is single-flight, success clears only the captured draft", () => {
|
||||
const store = createMessageEditorDraftStore()
|
||||
const a = store.get("1")
|
||||
const b = store.get("2")
|
||||
store.update(a, "For A")
|
||||
store.update(b, "For B")
|
||||
const sent = store.beginSend("1")
|
||||
assert.equal(store.beginSend("1"), null)
|
||||
assert.equal(store.beginSend("2"), null)
|
||||
assert.equal(store.finishSend(sent, true), true)
|
||||
assert.equal(a.html, "")
|
||||
assert.equal(b.html, "For B")
|
||||
assert.ok(store.beginSend("2"))
|
||||
})
|
||||
|
||||
test("typing during a pending send survives success, even after returning to identical text", () => {
|
||||
const store = createMessageEditorDraftStore()
|
||||
const draft = store.get("1")
|
||||
store.update(draft, "Initial")
|
||||
const sent = store.beginSend("1")
|
||||
store.update(draft, "Next draft")
|
||||
assert.equal(store.finishSend(sent, true), false)
|
||||
assert.equal(draft.html, "Next draft")
|
||||
const nextSend = store.beginSend("1")
|
||||
store.update(draft, "Changed")
|
||||
store.update(draft, "Next draft")
|
||||
assert.equal(store.finishSend(nextSend, true), false)
|
||||
assert.equal(draft.html, "Next draft")
|
||||
})
|
||||
|
||||
test("identity change clears private drafts and invalidates old completions", () => {
|
||||
const store = createMessageEditorDraftStore()
|
||||
store.setScope("account-a")
|
||||
const previous = store.get("1")
|
||||
store.update(previous, "Private")
|
||||
const sent = store.beginSend("1")
|
||||
store.setScope("account-b")
|
||||
const next = store.get("1")
|
||||
store.update(next, "New account")
|
||||
const nextSent = store.beginSend("1")
|
||||
assert.equal(store.contains("1", previous), false)
|
||||
assert.equal(store.finishSend(sent, true), false)
|
||||
assert.equal(next.html, "New account")
|
||||
assert.equal(store.finishSend(nextSent, true), true)
|
||||
})
|
||||
|
||||
test("a newly mounted editor observes completion from the previous editor instance", () => {
|
||||
const store = createMessageEditorDraftStore()
|
||||
const draft = store.get("1")
|
||||
store.update(draft, "Pending")
|
||||
const sent = store.beginSend("1")
|
||||
let rendered = draft.html
|
||||
const unsubscribe = store.subscribe((changed) => {
|
||||
if (changed === draft) rendered = changed.html
|
||||
})
|
||||
store.finishSend(sent, true)
|
||||
assert.equal(rendered, "")
|
||||
unsubscribe()
|
||||
})
|
||||
|
||||
test("pending status stays observable when switching or remounting editors", () => {
|
||||
const store = createMessageEditorDraftStore()
|
||||
store.get("1")
|
||||
const states = []
|
||||
const unsubscribe = store.subscribeSending(() => states.push(store.isSending()))
|
||||
const sent = store.beginSend("1")
|
||||
store.get("2")
|
||||
assert.equal(store.isSending(), true)
|
||||
store.finishSend(sent, false)
|
||||
assert.deepEqual(states, [true, false])
|
||||
unsubscribe()
|
||||
})
|
||||
95
web/lib/message-editor-drafts.ts
Normal file
95
web/lib/message-editor-drafts.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import type { UploadedEditorImage } from "./im-editor-image"
|
||||
|
||||
export type MessageEditorDraft = {
|
||||
html: string
|
||||
revision: number
|
||||
objectUrls: Set<string>
|
||||
uploadedImages: Map<string, UploadedEditorImage & { url: string }>
|
||||
}
|
||||
|
||||
type PendingDraftSend = {
|
||||
key: string
|
||||
draft: MessageEditorDraft
|
||||
revision: number
|
||||
}
|
||||
|
||||
/** In-memory only: neither messages nor temporary image URLs belong in storage. */
|
||||
export function createMessageEditorDraftStore() {
|
||||
const drafts = new Map<string, MessageEditorDraft>()
|
||||
let pendingSend: PendingDraftSend | null = null
|
||||
let scope: string | undefined
|
||||
const sendingListeners = new Set<() => void>()
|
||||
const notifySending = () => { for (const listener of sendingListeners) listener() }
|
||||
const listeners = new Set<(draft: MessageEditorDraft) => void>()
|
||||
const notify = (draft: MessageEditorDraft) => { for (const listener of listeners) listener(draft) }
|
||||
|
||||
function release(draft: MessageEditorDraft) {
|
||||
for (const url of draft.objectUrls) URL.revokeObjectURL(url)
|
||||
draft.objectUrls.clear()
|
||||
draft.uploadedImages.clear()
|
||||
}
|
||||
|
||||
function clear() {
|
||||
for (const draft of drafts.values()) release(draft)
|
||||
drafts.clear()
|
||||
pendingSend = null
|
||||
notifySending()
|
||||
}
|
||||
|
||||
return {
|
||||
clear,
|
||||
notify,
|
||||
subscribe(listener: (draft: MessageEditorDraft) => void) {
|
||||
listeners.add(listener)
|
||||
return () => { listeners.delete(listener) }
|
||||
},
|
||||
isSending() { return pendingSend !== null },
|
||||
subscribeSending(listener: () => void) {
|
||||
sendingListeners.add(listener)
|
||||
return () => { sendingListeners.delete(listener) }
|
||||
},
|
||||
setScope(nextScope: string) {
|
||||
if (scope !== nextScope) {
|
||||
clear()
|
||||
scope = nextScope
|
||||
}
|
||||
},
|
||||
get(key: string): MessageEditorDraft {
|
||||
let draft = drafts.get(key)
|
||||
if (!draft) {
|
||||
draft = { html: "", revision: 0, objectUrls: new Set(), uploadedImages: new Map() }
|
||||
drafts.set(key, draft)
|
||||
}
|
||||
return draft
|
||||
},
|
||||
contains(key: string, draft: MessageEditorDraft) {
|
||||
return drafts.get(key) === draft
|
||||
},
|
||||
update(draft: MessageEditorDraft, html: string) {
|
||||
draft.html = html
|
||||
draft.revision += 1
|
||||
},
|
||||
beginSend(key: string): PendingDraftSend | null {
|
||||
const draft = drafts.get(key)
|
||||
if (!draft || pendingSend) return null
|
||||
pendingSend = { key, draft, revision: draft.revision }
|
||||
notifySending()
|
||||
return pendingSend
|
||||
},
|
||||
finishSend(sent: PendingDraftSend, succeeded: boolean) {
|
||||
if (pendingSend !== sent) return false
|
||||
pendingSend = null
|
||||
notifySending()
|
||||
if (!succeeded || drafts.get(sent.key) !== sent.draft || sent.draft.revision !== sent.revision) {
|
||||
return false
|
||||
}
|
||||
sent.draft.html = ""
|
||||
sent.draft.revision += 1
|
||||
release(sent.draft)
|
||||
notify(sent.draft)
|
||||
return true
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export type MessageEditorDraftStore = ReturnType<typeof createMessageEditorDraftStore>
|
||||
14
web/lib/message-file-identity.test.mjs
Normal file
14
web/lib/message-file-identity.test.mjs
Normal file
@@ -0,0 +1,14 @@
|
||||
import assert from "node:assert/strict"
|
||||
import test from "node:test"
|
||||
import { getMessageFileIdentity } from "./message-file-identity.ts"
|
||||
|
||||
const file = (content, name = "attachment.txt") => ({ name, type: "text/plain",
|
||||
arrayBuffer: async () => new TextEncoder().encode(content).buffer })
|
||||
|
||||
test("reselecting identical file content keeps attachment retry identity", async () => {
|
||||
assert.equal(await getMessageFileIdentity(file("same")), await getMessageFileIdentity(file("same")))
|
||||
})
|
||||
|
||||
test("equal-name equal-size files with different bytes cannot share retry identity", async () => {
|
||||
assert.notEqual(await getMessageFileIdentity(file("left")), await getMessageFileIdentity(file("next")))
|
||||
})
|
||||
13
web/lib/message-file-identity.ts
Normal file
13
web/lib/message-file-identity.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Retry identity for a selected attachment. A file picker returns a fresh File
|
||||
* object on reselection; metadata alone can also collide across different bytes.
|
||||
*/
|
||||
export async function getMessageFileIdentity(file: Pick<File, "name" | "type" | "arrayBuffer">) {
|
||||
const bytes = await file.arrayBuffer()
|
||||
const subtle = globalThis.crypto?.subtle
|
||||
// Self-hosted HTTP deployments may lack WebCrypto. Exact bytes provide a
|
||||
// collision-free in-memory fallback, rather than silently weakening the key.
|
||||
const digest = subtle ? await subtle.digest("SHA-256", bytes) : bytes
|
||||
const key = Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("")
|
||||
return JSON.stringify([file.name, file.type, subtle ? "sha256" : "bytes", key])
|
||||
}
|
||||
@@ -38,6 +38,7 @@ export type DashboardNavItemConfig = Omit<DashboardNavMenuItem, "title"> & {
|
||||
* admin can see the module.
|
||||
*/
|
||||
requiredPermission?: string;
|
||||
requiredAnyPermission?: readonly string[];
|
||||
};
|
||||
|
||||
export type DashboardNavSectionConfig = {
|
||||
@@ -51,6 +52,9 @@ function navItemVisible(
|
||||
superAdmin: boolean,
|
||||
permissionSet: Set<string>,
|
||||
): boolean {
|
||||
if (item.requiredAnyPermission) {
|
||||
return item.requiredAnyPermission.some((permission) => permissionSet.has(permission));
|
||||
}
|
||||
if (superAdmin) {
|
||||
return true;
|
||||
}
|
||||
@@ -264,6 +268,12 @@ export const dashboardNavSections: DashboardNavSectionConfig[] = [
|
||||
titleKey: "nav.system",
|
||||
icon: <ShieldCheckIcon />,
|
||||
items: [
|
||||
{
|
||||
titleKey: "supportAvailability.title",
|
||||
url: "/dashboard/support-availability",
|
||||
icon: <CalendarClockIcon />,
|
||||
requiredAnyPermission: ["supportConfig.view", "agentTeamSchedule.view"],
|
||||
},
|
||||
{
|
||||
titleKey: "nav.setupWizard",
|
||||
url: "/dashboard/setup",
|
||||
|
||||
@@ -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 assert from "node:assert/strict"
|
||||
import test from "node:test"
|
||||
|
||||
@@ -208,3 +208,41 @@ test("becoming ineligible during authentication clears the opening deadline", as
|
||||
t.mock.timers.tick(200)
|
||||
assert.equal(errors, 0)
|
||||
})
|
||||
|
||||
test("disconnect invalidates a pending beforeConnect even across reconnect", async () => {
|
||||
let resolveOld
|
||||
let attempts = 0, created = 0
|
||||
const old = new Promise((resolve) => { resolveOld = resolve })
|
||||
const manager = createRealtimeConnectionManager({
|
||||
beforeConnect: () => ++attempts === 1 ? old : Promise.resolve(),
|
||||
createSocket: () => { created++; return new MockWebSocket() },
|
||||
})
|
||||
const first = manager.connect()
|
||||
manager.disconnect()
|
||||
await manager.connect()
|
||||
resolveOld()
|
||||
await first
|
||||
assert.equal(created, 1)
|
||||
manager.disconnect()
|
||||
})
|
||||
|
||||
test("late old socket close cannot clear current connection or notify current owner", async () => {
|
||||
let closed = 0
|
||||
const sockets = []
|
||||
const manager = createRealtimeConnectionManager({
|
||||
createSocket: () => {
|
||||
const socket = new MockWebSocket()
|
||||
// Simulate an asynchronously arriving close event after replacement.
|
||||
socket.close = () => { socket.readyState = MockWebSocket.CLOSED }
|
||||
sockets.push(socket)
|
||||
return socket
|
||||
},
|
||||
onClose: () => { closed++ },
|
||||
})
|
||||
await manager.connect()
|
||||
await manager.connect()
|
||||
sockets[0].emit("close", {})
|
||||
assert.equal(manager.getSocket(), sockets[1])
|
||||
assert.equal(closed, 0)
|
||||
manager.disconnect()
|
||||
})
|
||||
|
||||
@@ -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.
|
||||
"use client"
|
||||
|
||||
export type RealtimeConnectionStatus = "connecting" | "connected" | "disconnected"
|
||||
|
||||
11
web/lib/realtime-log-context.test.mjs
Normal file
11
web/lib/realtime-log-context.test.mjs
Normal file
@@ -0,0 +1,11 @@
|
||||
import assert from "node:assert/strict"
|
||||
import test from "node:test"
|
||||
import { realtimeLogContext } from "./realtime-log-context.ts"
|
||||
|
||||
test("socket diagnostics contain only origin and path, never URL credentials or query", () => {
|
||||
assert.deepEqual(realtimeLogContext("wss://user:password@example.test/api/ws/admin?accessToken=not-a-secret#fragment"), {
|
||||
origin: "wss://example.test",
|
||||
path: "/api/ws/admin",
|
||||
})
|
||||
assert.deepEqual(realtimeLogContext("invalid token-bearing input"), { origin: "", path: "" })
|
||||
})
|
||||
9
web/lib/realtime-log-context.ts
Normal file
9
web/lib/realtime-log-context.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
/** Never include WebSocket query parameters, userinfo, or arbitrary close reasons in logs. */
|
||||
export function realtimeLogContext(url: string) {
|
||||
try {
|
||||
const parsed = new URL(url)
|
||||
return { origin: parsed.origin, path: parsed.pathname }
|
||||
} catch {
|
||||
return { origin: "", path: "" }
|
||||
}
|
||||
}
|
||||
32
web/lib/session-request-guard.test.mjs
Normal file
32
web/lib/session-request-guard.test.mjs
Normal file
@@ -0,0 +1,32 @@
|
||||
import assert from "node:assert/strict"
|
||||
import test from "node:test"
|
||||
import { createSessionRequestGuard, isSameSession } from "./session-request-guard.ts"
|
||||
|
||||
const account = (id, accessToken) => ({ user: { id }, accessToken })
|
||||
|
||||
test("late profile success, error, and finally are stale after sign-out", () => {
|
||||
let current = account(1, "test-session-a")
|
||||
const guard = createSessionRequestGuard(() => current)
|
||||
const isCurrent = guard.begin(current)
|
||||
current = null
|
||||
assert.equal(isCurrent(), false)
|
||||
})
|
||||
|
||||
test("old account and rotated-token responses cannot clear the current session", () => {
|
||||
const previous = account(1, "test-session-a")
|
||||
assert.equal(isSameSession(previous, account(2, "test-session-b")), false)
|
||||
assert.equal(isSameSession(previous, account(1, "test-session-rotated")), false)
|
||||
assert.equal(isSameSession(null, account(1, "test-session-a")), false)
|
||||
assert.equal(isSameSession(previous, previous), true)
|
||||
})
|
||||
|
||||
test("only the latest profile request can commit, including same-token requests", () => {
|
||||
const current = account(1, "test-session-a")
|
||||
const guard = createSessionRequestGuard(() => current)
|
||||
const first = guard.begin(current)
|
||||
const second = guard.begin(current)
|
||||
assert.equal(first(), false)
|
||||
assert.equal(second(), true)
|
||||
guard.invalidate()
|
||||
assert.equal(second(), false)
|
||||
})
|
||||
20
web/lib/session-request-guard.ts
Normal file
20
web/lib/session-request-guard.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
// Kept transport-independent so all late session completions use the same rule.
|
||||
export type SessionIdentity = {
|
||||
accessToken: string
|
||||
user: { id: number }
|
||||
}
|
||||
|
||||
export function isSameSession(expected: SessionIdentity | null, current: SessionIdentity | null) {
|
||||
return Boolean(expected && current && expected.accessToken === current.accessToken && expected.user.id === current.user.id)
|
||||
}
|
||||
|
||||
export function createSessionRequestGuard(readCurrent: () => SessionIdentity | null) {
|
||||
let generation = 0
|
||||
return {
|
||||
invalidate() { generation += 1 },
|
||||
begin(session: SessionIdentity) {
|
||||
const requestGeneration = ++generation
|
||||
return () => generation === requestGeneration && isSameSession(session, readCurrent())
|
||||
},
|
||||
}
|
||||
}
|
||||
127
web/lib/stores/agent-conversations.test.mjs
Normal file
127
web/lib/stores/agent-conversations.test.mjs
Normal file
@@ -0,0 +1,127 @@
|
||||
import assert from "node:assert/strict"
|
||||
import test from "node:test"
|
||||
import { deferred, loadTypeScriptModule } from "../test-support/load-typescript-module.mjs"
|
||||
|
||||
function createStore(initialize) {
|
||||
let state
|
||||
const setState = (update, replace = false) => {
|
||||
const next = typeof update === "function" ? update(state) : update
|
||||
state = replace ? next : { ...state, ...next }
|
||||
}
|
||||
state = initialize(setState, () => state)
|
||||
const initial = state
|
||||
return { getState: () => state, setState, getInitialState: () => initial }
|
||||
}
|
||||
|
||||
function fixture(api = {}) {
|
||||
let session = { user: { id: 1 }, accessToken: "test-session-a" }
|
||||
const loaded = loadTypeScriptModule(new URL("./agent-conversations.ts", import.meta.url), {
|
||||
zustand: { create: createStore },
|
||||
"@/lib/api/agent": api,
|
||||
"@/lib/auth": { readSession: () => session },
|
||||
"@/lib/utils": { generateUUID: () => "test-id" },
|
||||
"@/lib/im-message": { summarizeIMMessage: () => "test-summary" },
|
||||
"@/lib/im-message-merge": {
|
||||
cursorFromLoadedImMessages: (messages) => String(messages[0]?.id ?? ""),
|
||||
hasMoreAfterLatestImMessageMerge: () => false,
|
||||
mergeImMessagesByIdAsc: (left, right) => [...left, ...right],
|
||||
parseImMessageCursorId: Number,
|
||||
},
|
||||
"@/lib/im-realtime-state": {
|
||||
patchConversationList: (list) => list,
|
||||
patchConversationListWithMessage: (list) => list,
|
||||
},
|
||||
"@/lib/agent-activity-state": {},
|
||||
"@/lib/operator-presence-state": {},
|
||||
})
|
||||
return {
|
||||
store: loaded.useAgentConversationsStore,
|
||||
reset: loaded.resetAgentConversations,
|
||||
set session(next) { session = next },
|
||||
}
|
||||
}
|
||||
|
||||
test("late conversation list cannot restore private data after reset/account change", async () => {
|
||||
const old = deferred()
|
||||
const f = fixture({ fetchAgentConversations: () => old.promise })
|
||||
const loading = f.store.getState().loadConversations()
|
||||
f.session = { user: { id: 2 }, accessToken: "test-session-b" }
|
||||
f.reset()
|
||||
f.store.setState({ conversations: [{ id: 20 }], selectedConversationId: 20 })
|
||||
old.resolve({ results: [{ id: 10 }] })
|
||||
await loading
|
||||
assert.equal(f.store.getState().conversations[0].id, 20)
|
||||
assert.equal(f.store.getState().selectedConversationId, 20)
|
||||
})
|
||||
|
||||
test("same-account relogin invalidates old load generations too", async () => {
|
||||
const old = deferred()
|
||||
const f = fixture({ fetchAgentMessages: () => old.promise })
|
||||
f.store.setState({ selectedConversationId: 10 })
|
||||
const loading = f.store.getState().loadMessages(10)
|
||||
f.reset()
|
||||
f.store.setState({ selectedConversationId: 10, messages: [{ id: 200 }] })
|
||||
old.resolve({ results: [{ id: 100 }] })
|
||||
await loading
|
||||
assert.equal(f.store.getState().messages[0].id, 200)
|
||||
})
|
||||
|
||||
test("operator send binds its recipient, is single-flight, and does not patch the new selection", async () => {
|
||||
const response = deferred()
|
||||
const calls = []
|
||||
const f = fixture({ sendAgentMessage: (payload) => { calls.push(payload); return response.promise } })
|
||||
f.store.setState({ selectedConversationId: 10 })
|
||||
assert.equal(await f.store.getState().sendMessage("Wrong recipient", 20), null)
|
||||
const sending = f.store.getState().sendMessage("For A", 10)
|
||||
assert.equal(await f.store.getState().sendMessage("Duplicate", 10), null)
|
||||
f.store.setState({ selectedConversationId: 20, messages: [{ id: 200 }] })
|
||||
response.resolve({ id: 100, conversationId: 10 })
|
||||
await sending
|
||||
assert.equal(calls.length, 1)
|
||||
assert.equal(calls[0].conversationId, 10)
|
||||
assert.equal(f.store.getState().messages[0].id, 200)
|
||||
})
|
||||
|
||||
test("old send finally does not unlock a new account's pending send", async () => {
|
||||
const old = deferred()
|
||||
const f = fixture({ sendAgentMessage: () => old.promise })
|
||||
f.store.setState({ selectedConversationId: 10 })
|
||||
const sending = f.store.getState().sendMessage("For A", 10)
|
||||
f.reset()
|
||||
f.store.setState({ sending: true, selectedConversationId: 20 })
|
||||
old.resolve({ id: 100, conversationId: 10 })
|
||||
await sending
|
||||
assert.equal(f.store.getState().sending, true)
|
||||
})
|
||||
|
||||
test("attachment upload cannot send under a new account after identity change", async () => {
|
||||
const upload = deferred()
|
||||
let sends = 0
|
||||
const f = fixture({
|
||||
uploadAgentConversationAttachment: () => upload.promise,
|
||||
sendAgentMessage: async () => { sends += 1; return { id: 100 } },
|
||||
})
|
||||
f.store.setState({ selectedConversationId: 10 })
|
||||
const pending = f.store.getState().sendAttachment({}, 10)
|
||||
f.session = { user: { id: 2 }, accessToken: "test-session-b" }
|
||||
f.reset()
|
||||
upload.resolve({ assetId: "test", filename: "test.txt" })
|
||||
assert.equal(await pending, null)
|
||||
assert.equal(sends, 0)
|
||||
})
|
||||
|
||||
test("a fresh snapshot invalidates old pagination without leaving its loading flag stuck", async () => {
|
||||
const oldPage = deferred()
|
||||
const fresh = deferred()
|
||||
const f = fixture({ fetchAgentMessages: (params) => params.cursor ? oldPage.promise : fresh.promise })
|
||||
f.store.setState({ selectedConversationId: 10, messagesCursor: "100", messagesHasMore: true })
|
||||
const older = f.store.getState().loadOlderMessages()
|
||||
assert.equal(f.store.getState().messagesLoadingMore, true)
|
||||
const refresh = f.store.getState().loadMessages(10)
|
||||
fresh.resolve({ results: [{ id: 200 }], hasMore: true })
|
||||
await refresh
|
||||
oldPage.resolve({ results: [{ id: 50 }], hasMore: false })
|
||||
await older
|
||||
assert.equal(f.store.getState().messages[0].id, 200)
|
||||
assert.equal(f.store.getState().messagesLoadingMore, false)
|
||||
})
|
||||
@@ -1,3 +1,5 @@
|
||||
// Modified in the independent hardening fork; see docs/hardening/README.md.
|
||||
// Modified for AgentDeskFixes: guard private state, captured recipients, and stale async completions.
|
||||
"use client"
|
||||
|
||||
import { create } from "zustand"
|
||||
@@ -104,9 +106,9 @@ type AgentConversationsStore = {
|
||||
loadOlderMessages: () => Promise<void>
|
||||
syncLatestMessages: (conversationId: number) => Promise<void>
|
||||
markSelectedConversationRead: () => Promise<void>
|
||||
sendMessage: (html: string) => Promise<AgentMessage | null>
|
||||
uploadImage: (file: File) => Promise<AgentAsset | null>
|
||||
sendAttachment: (file: File) => Promise<AgentMessage | null>
|
||||
sendMessage: (html: string, conversationId?: number) => Promise<AgentMessage | null>
|
||||
uploadImage: (file: File, conversationId?: number) => Promise<AgentAsset | null>
|
||||
sendAttachment: (file: File, conversationId?: number) => Promise<AgentMessage | null>
|
||||
recallMessage: (messageId: number) => Promise<AgentMessage | null>
|
||||
applyRealtimeMessageCreated: (message: AgentMessage) => void
|
||||
applyRealtimeAgentActivity: (payload: AgentActivityEventPayload) => void
|
||||
@@ -119,6 +121,22 @@ type AgentConversationsStore = {
|
||||
|
||||
let conversationsRequestSeq = 0
|
||||
let messagesRequestSeq = 0
|
||||
let sessionGeneration = 0
|
||||
|
||||
function captureSessionScope() {
|
||||
const generation = sessionGeneration
|
||||
const userId = readSession()?.user.id
|
||||
return () =>
|
||||
generation === sessionGeneration && Boolean(userId) && readSession()?.user.id === userId
|
||||
}
|
||||
|
||||
/** Invalidates every outstanding request before removing private dashboard data. */
|
||||
export function resetAgentConversations() {
|
||||
sessionGeneration += 1
|
||||
conversationsRequestSeq += 1
|
||||
messagesRequestSeq += 1
|
||||
useAgentConversationsStore.setState(useAgentConversationsStore.getInitialState(), true)
|
||||
}
|
||||
|
||||
export const useAgentConversationsStore = create<AgentConversationsStore>((set, get) => ({
|
||||
searchKeyword: "",
|
||||
@@ -167,6 +185,8 @@ export const useAgentConversationsStore = create<AgentConversationsStore>((set,
|
||||
},
|
||||
|
||||
loadConversations: async () => {
|
||||
const isCurrentSession = captureSessionScope()
|
||||
if (!isCurrentSession()) return
|
||||
const requestSeq = ++conversationsRequestSeq
|
||||
const store = get()
|
||||
|
||||
@@ -180,7 +200,7 @@ export const useAgentConversationsStore = create<AgentConversationsStore>((set,
|
||||
)
|
||||
const conversations = ensureArray(data.results)
|
||||
|
||||
if (requestSeq !== conversationsRequestSeq) {
|
||||
if (requestSeq !== conversationsRequestSeq || !isCurrentSession()) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -216,7 +236,7 @@ export const useAgentConversationsStore = create<AgentConversationsStore>((set,
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
if (requestSeq === conversationsRequestSeq) {
|
||||
if (requestSeq === conversationsRequestSeq && isCurrentSession()) {
|
||||
set({ conversationsLoading: false })
|
||||
}
|
||||
throw error
|
||||
@@ -246,10 +266,14 @@ export const useAgentConversationsStore = create<AgentConversationsStore>((set,
|
||||
},
|
||||
|
||||
loadMessages: async (conversationId, options = {}) => {
|
||||
const isCurrentSession = captureSessionScope()
|
||||
if (!isCurrentSession() || get().selectedConversationId !== conversationId) return
|
||||
const requestSeq = ++messagesRequestSeq
|
||||
const store = get()
|
||||
const shouldShowLoading =
|
||||
options.forceLoading || store.messagesLoadedConversationId !== conversationId
|
||||
// A fresh snapshot supersedes any older page request in the prior generation.
|
||||
set({ messagesLoadingMore: false })
|
||||
|
||||
if (shouldShowLoading) {
|
||||
set({
|
||||
@@ -270,7 +294,7 @@ export const useAgentConversationsStore = create<AgentConversationsStore>((set,
|
||||
limit: 50,
|
||||
})
|
||||
|
||||
if (requestSeq !== messagesRequestSeq) {
|
||||
if (requestSeq !== messagesRequestSeq || !isCurrentSession()) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -288,7 +312,7 @@ export const useAgentConversationsStore = create<AgentConversationsStore>((set,
|
||||
messagesHasMore: Boolean(data.hasMore),
|
||||
})
|
||||
} catch (error) {
|
||||
if (requestSeq === messagesRequestSeq) {
|
||||
if (requestSeq === messagesRequestSeq && isCurrentSession()) {
|
||||
set({ messagesLoading: false })
|
||||
}
|
||||
throw error
|
||||
@@ -296,6 +320,9 @@ export const useAgentConversationsStore = create<AgentConversationsStore>((set,
|
||||
},
|
||||
|
||||
loadOlderMessages: async () => {
|
||||
const isCurrentSession = captureSessionScope()
|
||||
if (!isCurrentSession()) return
|
||||
const selectionGeneration = messagesRequestSeq
|
||||
const conversationId = get().selectedConversationId
|
||||
if (!conversationId || get().messagesLoadingMore || !get().messagesHasMore) {
|
||||
return
|
||||
@@ -312,7 +339,7 @@ export const useAgentConversationsStore = create<AgentConversationsStore>((set,
|
||||
cursor: cursorId,
|
||||
limit: 50,
|
||||
})
|
||||
if (get().selectedConversationId !== conversationId) {
|
||||
if (!isCurrentSession() || selectionGeneration !== messagesRequestSeq || get().selectedConversationId !== conversationId) {
|
||||
return
|
||||
}
|
||||
const incoming = ensureArray(data.results)
|
||||
@@ -328,12 +355,17 @@ export const useAgentConversationsStore = create<AgentConversationsStore>((set,
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
set({ messagesLoadingMore: false })
|
||||
if (isCurrentSession() && selectionGeneration === messagesRequestSeq && get().selectedConversationId === conversationId) {
|
||||
set({ messagesLoadingMore: false })
|
||||
}
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
syncLatestMessages: async (conversationId) => {
|
||||
const isCurrentSession = captureSessionScope()
|
||||
if (!isCurrentSession()) return
|
||||
const selectionGeneration = messagesRequestSeq
|
||||
if (conversationId <= 0) {
|
||||
return
|
||||
}
|
||||
@@ -342,7 +374,7 @@ export const useAgentConversationsStore = create<AgentConversationsStore>((set,
|
||||
conversationId,
|
||||
limit: 50,
|
||||
})
|
||||
if (get().selectedConversationId !== conversationId) {
|
||||
if (!isCurrentSession() || selectionGeneration !== messagesRequestSeq || get().selectedConversationId !== conversationId) {
|
||||
return
|
||||
}
|
||||
const batch = ensureArray(data.results)
|
||||
@@ -370,6 +402,8 @@ export const useAgentConversationsStore = create<AgentConversationsStore>((set,
|
||||
},
|
||||
|
||||
markSelectedConversationRead: async () => {
|
||||
const isCurrentSession = captureSessionScope()
|
||||
if (!isCurrentSession()) return
|
||||
const store = get()
|
||||
const conversationId = store.selectedConversationId
|
||||
const conversation = store.conversations.find((item) => item.id === conversationId)
|
||||
@@ -390,6 +424,7 @@ export const useAgentConversationsStore = create<AgentConversationsStore>((set,
|
||||
set({ readingMessageId: lastMessage.id })
|
||||
try {
|
||||
await markAgentMessageRead(conversationId, lastMessage.id)
|
||||
if (!isCurrentSession() || get().readingMessageId !== lastMessage.id) return
|
||||
set((current) => {
|
||||
if (current.selectedConversationId !== conversationId) {
|
||||
return { readingMessageId: 0 }
|
||||
@@ -414,7 +449,9 @@ export const useAgentConversationsStore = create<AgentConversationsStore>((set,
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
set({ readingMessageId: 0 })
|
||||
if (isCurrentSession() && get().readingMessageId === lastMessage.id) {
|
||||
set({ readingMessageId: 0 })
|
||||
}
|
||||
throw error
|
||||
}
|
||||
},
|
||||
@@ -513,7 +550,10 @@ export const useAgentConversationsStore = create<AgentConversationsStore>((set,
|
||||
},
|
||||
|
||||
resyncRealtimeData: async (conversationId) => {
|
||||
const isCurrentSession = captureSessionScope()
|
||||
if (!isCurrentSession()) return
|
||||
await get().loadConversations()
|
||||
if (!isCurrentSession()) return
|
||||
const selectedConversationId = get().selectedConversationId
|
||||
const targetConversationId = conversationId ?? selectedConversationId
|
||||
if (targetConversationId && selectedConversationId === targetConversationId) {
|
||||
@@ -524,10 +564,15 @@ export const useAgentConversationsStore = create<AgentConversationsStore>((set,
|
||||
}
|
||||
},
|
||||
|
||||
sendMessage: async (html) => {
|
||||
sendMessage: async (html, recipientId) => {
|
||||
const isCurrentSession = captureSessionScope()
|
||||
const trimmedContent = html.trim()
|
||||
const { selectedConversationId, sending } = get()
|
||||
if (!selectedConversationId || !trimmedContent || sending) {
|
||||
if (
|
||||
!isCurrentSession() || !selectedConversationId ||
|
||||
(recipientId !== undefined && recipientId !== selectedConversationId) ||
|
||||
!trimmedContent || sending || get().uploadingAsset
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -540,7 +585,7 @@ export const useAgentConversationsStore = create<AgentConversationsStore>((set,
|
||||
clientMsgId: `agent_${generateUUID()}`,
|
||||
})
|
||||
|
||||
if (get().selectedConversationId === selectedConversationId) {
|
||||
if (isCurrentSession() && get().selectedConversationId === selectedConversationId) {
|
||||
set((current) => ({
|
||||
messages: current.messages.some((m) => m.id === message.id)
|
||||
? current.messages.map((m) => (m.id === message.id ? message : m))
|
||||
@@ -561,33 +606,45 @@ export const useAgentConversationsStore = create<AgentConversationsStore>((set,
|
||||
|
||||
return message
|
||||
} finally {
|
||||
set({ sending: false })
|
||||
if (isCurrentSession()) set({ sending: false })
|
||||
}
|
||||
},
|
||||
|
||||
uploadImage: async (file) => {
|
||||
uploadImage: async (file, recipientId) => {
|
||||
const isCurrentSession = captureSessionScope()
|
||||
const { selectedConversationId, sending, uploadingAsset } = get()
|
||||
if (!selectedConversationId || sending || uploadingAsset) {
|
||||
if (
|
||||
!isCurrentSession() || !selectedConversationId ||
|
||||
(recipientId !== undefined && recipientId !== selectedConversationId) ||
|
||||
sending || uploadingAsset
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
set({ uploadingAsset: true })
|
||||
try {
|
||||
return await uploadAgentConversationImage(selectedConversationId, file)
|
||||
const asset = await uploadAgentConversationImage(selectedConversationId, file)
|
||||
return isCurrentSession() ? asset : null
|
||||
} finally {
|
||||
set({ uploadingAsset: false })
|
||||
if (isCurrentSession()) set({ uploadingAsset: false })
|
||||
}
|
||||
},
|
||||
|
||||
sendAttachment: async (file) => {
|
||||
sendAttachment: async (file, recipientId) => {
|
||||
const isCurrentSession = captureSessionScope()
|
||||
const { selectedConversationId, sending, uploadingAsset } = get()
|
||||
if (!selectedConversationId || sending || uploadingAsset) {
|
||||
if (
|
||||
!isCurrentSession() || !selectedConversationId ||
|
||||
(recipientId !== undefined && recipientId !== selectedConversationId) ||
|
||||
sending || uploadingAsset
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
set({ uploadingAsset: true })
|
||||
try {
|
||||
const asset = await uploadAgentConversationAttachment(selectedConversationId, file)
|
||||
if (!isCurrentSession()) return null
|
||||
const message = await sendAgentMessage({
|
||||
conversationId: selectedConversationId,
|
||||
messageType: "attachment",
|
||||
@@ -596,7 +653,7 @@ export const useAgentConversationsStore = create<AgentConversationsStore>((set,
|
||||
clientMsgId: `agent_attachment_${generateUUID()}`,
|
||||
})
|
||||
|
||||
if (get().selectedConversationId === selectedConversationId) {
|
||||
if (isCurrentSession() && get().selectedConversationId === selectedConversationId) {
|
||||
set((current) => ({
|
||||
messages: current.messages.some((m) => m.id === message.id)
|
||||
? current.messages.map((m) => (m.id === message.id ? message : m))
|
||||
@@ -617,20 +674,22 @@ export const useAgentConversationsStore = create<AgentConversationsStore>((set,
|
||||
|
||||
return message
|
||||
} finally {
|
||||
set({ uploadingAsset: false })
|
||||
if (isCurrentSession()) set({ uploadingAsset: false })
|
||||
}
|
||||
},
|
||||
|
||||
recallMessage: async (messageId) => {
|
||||
const isCurrentSession = captureSessionScope()
|
||||
const { selectedConversationId, recallingMessageId } = get()
|
||||
if (!selectedConversationId || messageId <= 0 || recallingMessageId === messageId) {
|
||||
if (!isCurrentSession() || !selectedConversationId || messageId <= 0 || recallingMessageId === messageId) {
|
||||
return null
|
||||
}
|
||||
|
||||
set({ recallingMessageId: messageId })
|
||||
try {
|
||||
const message = await recallAgentMessage(messageId)
|
||||
if (get().selectedConversationId === selectedConversationId) {
|
||||
if (!isCurrentSession()) return null
|
||||
if (isCurrentSession() && get().selectedConversationId === selectedConversationId) {
|
||||
set((current) => {
|
||||
const nextMessages = current.messages.map((item) =>
|
||||
item.id === message.id ? message : item
|
||||
@@ -656,11 +715,11 @@ export const useAgentConversationsStore = create<AgentConversationsStore>((set,
|
||||
}
|
||||
})
|
||||
} else {
|
||||
set({ recallingMessageId: 0 })
|
||||
if (isCurrentSession() && get().recallingMessageId === messageId) set({ recallingMessageId: 0 })
|
||||
}
|
||||
return message
|
||||
} catch (error) {
|
||||
set({ recallingMessageId: 0 })
|
||||
if (isCurrentSession() && get().recallingMessageId === messageId) set({ recallingMessageId: 0 })
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
@@ -9,7 +9,8 @@ const deferred = () => {
|
||||
const promise = new Promise((done) => { resolve = done })
|
||||
return { promise, resolve }
|
||||
}
|
||||
const conversation = (id) => ({ id, status: 3, customerUnreadCount: 0 })
|
||||
const conversation = (id) => ({ id, channelId: 1, status: 3, lastMessageId: 0,
|
||||
customerUnreadCount: 0, agentUnreadCount: 0, customerLastReadMessageId: 0, agentLastReadMessageId: 0 })
|
||||
const message = (id, conversationId) => ({ id, conversationId, senderType: "agent",
|
||||
senderId: 1, messageType: "html", content: "Reply", sendStatus: 1,
|
||||
customerRead: false, agentRead: false })
|
||||
@@ -24,6 +25,8 @@ function harness(overrides = {}) {
|
||||
const config = { channelId: "fixture", externalId: "visitor-1" }
|
||||
const document = { visibilityState: "visible" }
|
||||
const api = {
|
||||
getCustomerIdentityScope: () => JSON.stringify(config),
|
||||
getCustomerEntryScope: () => JSON.stringify(config),
|
||||
ensureCustomerSession: async () => ({}),
|
||||
fetchImWidgetConfig: async () => ({}),
|
||||
fetchImConversationList: async () => page([conversation(1)]),
|
||||
@@ -41,6 +44,7 @@ function harness(overrides = {}) {
|
||||
return { getState: () => state, setState: set }
|
||||
} },
|
||||
"@/lib/api/im": api,
|
||||
"@/lib/message-file-identity": { getMessageFileIdentity: async () => "fixture-file" },
|
||||
"@/lib/im-realtime": { createImRealtimeConnection: () => {} },
|
||||
"@/lib/im-message": { summarizeIMMessage: (item) => item.content, parseMessageAssetPayload: () => null },
|
||||
"@/lib/utils": { generateUUID: () => "fixture" },
|
||||
@@ -67,9 +71,9 @@ function harness(overrides = {}) {
|
||||
const compiled = ts.transpileModule(source, { compilerOptions: {
|
||||
target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS,
|
||||
} })
|
||||
const module = { exports: {} }
|
||||
modules.set(name, module.exports)
|
||||
vm.runInNewContext(compiled.outputText, { module, exports: module.exports,
|
||||
const moduleContext = { exports: {} }
|
||||
modules.set(name, moduleContext.exports)
|
||||
vm.runInNewContext(compiled.outputText, { module: moduleContext, exports: moduleContext.exports,
|
||||
require: load, console, document, AbortController, WebSocket: { OPEN: 1, CONNECTING: 0 },
|
||||
setTimeout: (callback, delay) => {
|
||||
const id = ++nextTimer
|
||||
@@ -78,10 +82,10 @@ function harness(overrides = {}) {
|
||||
},
|
||||
clearTimeout: (id) => timers.delete(id),
|
||||
})
|
||||
return module.exports
|
||||
return moduleContext.exports
|
||||
}
|
||||
const store = load("./support-chat.ts").useSupportChatStore
|
||||
store.setState({ initialized: true, isOpen: true, isVisible: true,
|
||||
store.setState({ identityScope: api.getCustomerIdentityScope(), initialized: true, isOpen: true, isVisible: true,
|
||||
conversations: [conversation(1)], conversation: conversation(1), activeConversationId: 1 })
|
||||
return { store, api, config, document, timers, sent,
|
||||
get connects() { return connects }, get disconnects() { return disconnects },
|
||||
|
||||
289
web/lib/stores/support-chat.test.mjs
Normal file
289
web/lib/stores/support-chat.test.mjs
Normal file
@@ -0,0 +1,289 @@
|
||||
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 deferred = () => {
|
||||
let resolve, reject
|
||||
const promise = new Promise((yes, no) => { resolve = yes; reject = no })
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
const conversation = (id, extra = {}) => ({ id, channelId: 1, customerName: "Visitor", status: 3,
|
||||
serviceMode: 1, priority: 0, currentAssigneeId: 0, lastMessageId: 0,
|
||||
customerUnreadCount: 0, agentUnreadCount: 0, customerLastReadMessageId: 0,
|
||||
agentLastReadMessageId: 0, ...extra })
|
||||
const message = (id, conversationId, extra = {}) => ({ id, conversationId,
|
||||
senderType: "customer", senderId: 1, messageType: "html", content: `<p>${id}</p>`,
|
||||
sendStatus: 1, customerRead: false, agentRead: false, ...extra })
|
||||
const page = (results, hasMore = false) => ({ results, hasMore })
|
||||
|
||||
// Exercise the actual store, including its asynchronous continuations, without
|
||||
// connecting a socket, browser, database, or service.
|
||||
function harness(overrides = {}) {
|
||||
let identity = "visitor-1", uuid = 0, state, realtimeOptions
|
||||
let connectCount = 0, disconnectCount = 0
|
||||
const timers = new Map()
|
||||
const api = {
|
||||
getCustomerIdentityScope: () => identity,
|
||||
getCustomerEntryScope: () => identity,
|
||||
ensureCustomerSession: async () => ({}),
|
||||
fetchImWidgetConfig: async () => ({}),
|
||||
fetchImConversationList: async () => page([conversation(1), conversation(2)]),
|
||||
createOrMatchImConversation: async () => conversation(1),
|
||||
fetchImMessages: async () => page([]),
|
||||
createImConversation: async () => conversation(3),
|
||||
markImMessageRead: async () => {},
|
||||
closeImConversation: async () => {},
|
||||
applyCustomerSessionRefresh: () => {},
|
||||
...overrides,
|
||||
}
|
||||
const modules = new Map()
|
||||
const mocks = {
|
||||
zustand: { create: (initializer) => {
|
||||
const set = (patch) => { state = { ...state, ...(typeof patch === "function" ? patch(state) : patch) } }
|
||||
state = initializer(set, () => state)
|
||||
return { getState: () => state, setState: set }
|
||||
} },
|
||||
"@/lib/api/im": api,
|
||||
"@/lib/message-file-identity": { getMessageFileIdentity: async (file) => file.content },
|
||||
"@/lib/im-realtime": { createImRealtimeConnection: () => {} },
|
||||
"@/lib/im-message": { summarizeIMMessage: (item) => item.content, parseMessageAssetPayload: () => null },
|
||||
"@/lib/utils": { generateUUID: () => `uuid-${++uuid}` },
|
||||
"@/i18n/messages": { translateCurrentMessage: (key) => key },
|
||||
"@/lib/sdk/runtime-config": { readSupportChatRuntimeConfig: () => ({}), setSupportChatRuntimeConfig: () => {} },
|
||||
"@/lib/realtime-connection": { createRealtimeConnectionManager: (options) => {
|
||||
realtimeOptions = options
|
||||
return { connect: async () => { connectCount++ }, disconnect: () => { disconnectCount++ } }
|
||||
} },
|
||||
}
|
||||
const load = (name) => {
|
||||
if (mocks[name]) return mocks[name]
|
||||
if (modules.has(name)) return modules.get(name)
|
||||
const url = name.startsWith("@/")
|
||||
? new URL(`../../${name.slice(2)}.ts`, import.meta.url)
|
||||
: new URL(name, import.meta.url)
|
||||
const source = readFileSync(url, "utf8")
|
||||
const compiled = ts.transpileModule(source, { compilerOptions: {
|
||||
target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS,
|
||||
} })
|
||||
const moduleContext = { exports: {} }
|
||||
modules.set(name, moduleContext.exports)
|
||||
vm.runInNewContext(compiled.outputText, { module: moduleContext, exports: moduleContext.exports, require: load,
|
||||
console, AbortController, setTimeout: (callback) => { const id = timers.size + 1; timers.set(id, callback); return id },
|
||||
clearTimeout: (id) => timers.delete(id), WebSocket: { OPEN: 1, CONNECTING: 0 } })
|
||||
return moduleContext.exports
|
||||
}
|
||||
const store = load("./support-chat.ts").useSupportChatStore
|
||||
const seed = (extra = {}) => store.setState({ identityScope: identity, initialized: true,
|
||||
conversations: [conversation(1), conversation(2)], activeConversationId: 1,
|
||||
conversation: conversation(1), ...extra })
|
||||
seed()
|
||||
return { store, api, seed, timers, setIdentity: (value) => { identity = value },
|
||||
emit: (type, data) => realtimeOptions.onMessage({ data: JSON.stringify({ type, data }) }),
|
||||
get connectCount() { return connectCount }, get disconnectCount() { return disconnectCount } }
|
||||
}
|
||||
|
||||
for (const loader of ["refreshMessages", "syncLatestMessages", "loadOlderMessages"]) {
|
||||
test(`${loader} cannot paint a late response into a newly selected request`, async () => {
|
||||
const late = deferred()
|
||||
const h = harness({ fetchImMessages: ({ conversationId }) => conversationId === 1 ? late.promise : Promise.resolve(page([message(20, 2)])) })
|
||||
h.seed({ messages: [message(10, 1)], messagesHasMore: true, messagesCursor: "10" })
|
||||
const pending = h.store.getState()[loader]()
|
||||
await h.store.getState().selectConversation(2)
|
||||
late.resolve(page([message(5, 1)], true))
|
||||
await pending
|
||||
assert.equal(h.store.getState().conversation.id, 2)
|
||||
assert.deepEqual(Array.from(h.store.getState().messages, (item) => item.conversationId), [2])
|
||||
assert.equal(h.store.getState().messagesLoadingMore, false)
|
||||
})
|
||||
}
|
||||
|
||||
test("A to B to A selection invalidates the first A response", async () => {
|
||||
const late = deferred()
|
||||
let calls = 0
|
||||
const h = harness({ fetchImMessages: ({ conversationId }) => conversationId === 1 && calls++ === 0
|
||||
? late.promise : Promise.resolve(page([message(30, conversationId)])) })
|
||||
const pending = h.store.getState().refreshMessages()
|
||||
await h.store.getState().selectConversation(2)
|
||||
await h.store.getState().selectConversation(1)
|
||||
late.resolve(page([message(10, 1)]))
|
||||
await pending
|
||||
assert.deepEqual(Array.from(h.store.getState().messages, (item) => item.id), [30])
|
||||
})
|
||||
|
||||
test("newer latest request wins and stale failure cannot replace its error state", async () => {
|
||||
const old = deferred(), current = deferred()
|
||||
let calls = 0
|
||||
const h = harness({ fetchImMessages: () => ++calls === 1 ? old.promise : current.promise })
|
||||
const first = h.store.getState().refreshMessages()
|
||||
const second = h.store.getState().refreshMessages()
|
||||
current.resolve(page([message(30, 1)]))
|
||||
await second
|
||||
old.reject(new Error("stale error"))
|
||||
await first
|
||||
assert.equal(h.store.getState().error, "")
|
||||
assert.equal(h.store.getState().messages[0].id, 30)
|
||||
})
|
||||
|
||||
test("read acknowledgment updates its original list row without marking the new view", async () => {
|
||||
const read = deferred()
|
||||
const h = harness({ markImMessageRead: () => read.promise, fetchImMessages: async () => page([message(20, 2)]) })
|
||||
const first = conversation(1, { lastMessageId: 10, customerUnreadCount: 1 })
|
||||
h.seed({ conversations: [first, conversation(2, { customerUnreadCount: 2 })], conversation: first, messages: [message(10, 1)] })
|
||||
const pending = h.store.getState().markConversationRead()
|
||||
await h.store.getState().selectConversation(2)
|
||||
read.resolve()
|
||||
await pending
|
||||
const state = h.store.getState()
|
||||
assert.equal(state.conversations.find((item) => item.id === 1).customerUnreadCount, 0)
|
||||
assert.equal(state.conversation.customerUnreadCount, 2)
|
||||
assert.equal(state.messages[0].customerRead, false)
|
||||
})
|
||||
|
||||
test("read acknowledgment does not erase a newer unread reply", async () => {
|
||||
const read = deferred()
|
||||
const h = harness({ markImMessageRead: () => read.promise })
|
||||
const first = conversation(1, { lastMessageId: 10, customerUnreadCount: 1 })
|
||||
h.seed({ conversations: [first], conversation: first, messages: [message(10, 1)] })
|
||||
const pending = h.store.getState().markConversationRead()
|
||||
h.emit("conversation.updated", { conversationId: 1, lastMessageId: 11, customerUnreadCount: 2 })
|
||||
read.resolve()
|
||||
await pending
|
||||
assert.equal(h.store.getState().conversation.customerUnreadCount, 2)
|
||||
assert.equal(h.store.getState().conversation.customerLastReadMessageId, 10)
|
||||
})
|
||||
|
||||
test("send is single-flight and failed logical send keeps its idempotency key", async () => {
|
||||
const first = deferred(), sent = []
|
||||
const h = harness({ sendImMessage: (payload) => { sent.push(payload); return sent.length === 1 ? first.promise : Promise.resolve(message(10, 1)) } })
|
||||
const pending = h.store.getState().handleSendMessage("<p>Hello</p>")
|
||||
await assert.rejects(h.store.getState().handleSendMessage("<p>Hello</p>"), /sendInProgress/)
|
||||
assert.equal(sent.length, 1)
|
||||
first.reject(new Error("response lost"))
|
||||
await assert.rejects(pending, /response lost/)
|
||||
await h.store.getState().handleSendMessage("<p>Hello</p>")
|
||||
assert.equal(sent[0].clientMsgId, sent[1].clientMsgId)
|
||||
await h.store.getState().handleSendMessage("<p>Hello</p>")
|
||||
assert.notEqual(sent[1].clientMsgId, sent[2].clientMsgId)
|
||||
})
|
||||
|
||||
test("delivered send after switching updates only original request summary", async () => {
|
||||
const send = deferred()
|
||||
const h = harness({ sendImMessage: () => send.promise, fetchImMessages: async () => page([message(20, 2)]) })
|
||||
const pending = h.store.getState().handleSendMessage("hello")
|
||||
await h.store.getState().selectConversation(2)
|
||||
assert.equal(h.store.getState().sending, false)
|
||||
send.resolve(message(10, 1))
|
||||
await pending
|
||||
const state = h.store.getState()
|
||||
assert.equal(state.conversations.find((item) => item.id === 1).lastMessageId, 10)
|
||||
assert.equal(state.conversation.lastMessageId, 0)
|
||||
assert.equal(state.messages[0].conversationId, 2)
|
||||
})
|
||||
|
||||
test("close response for old selection does not close or disconnect current request", async () => {
|
||||
const close = deferred()
|
||||
const h = harness({ closeImConversation: () => close.promise })
|
||||
const pending = h.store.getState().closeConversation()
|
||||
await h.store.getState().selectConversation(2)
|
||||
close.resolve()
|
||||
await pending
|
||||
assert.equal(h.store.getState().conversation.status, 3)
|
||||
assert.equal(h.store.getState().conversations.find((item) => item.id === 1).status, 4)
|
||||
assert.equal(h.disconnectCount, 0)
|
||||
})
|
||||
|
||||
test("identity change invalidates old delivered result even for same numeric conversation", async () => {
|
||||
const send = deferred()
|
||||
const h = harness({ sendImMessage: () => send.promise })
|
||||
const pending = h.store.getState().handleSendMessage("private")
|
||||
h.setIdentity("visitor-2")
|
||||
h.seed()
|
||||
send.resolve(message(10, 1))
|
||||
await pending
|
||||
assert.equal(h.store.getState().messages.length, 0)
|
||||
assert.equal(h.store.getState().conversation.lastMessageId, 0)
|
||||
})
|
||||
|
||||
test("unmount invalidates pending bootstrap and prevents sockets from reopening", async () => {
|
||||
const config = deferred()
|
||||
const h = harness({ fetchImWidgetConfig: () => config.promise })
|
||||
h.seed({ initialized: false, isOpen: true, conversation: null, activeConversationId: 0 })
|
||||
const pending = h.store.getState().bootstrap()
|
||||
h.store.getState().disconnectSocket()
|
||||
config.resolve({ title: "stale" })
|
||||
await pending
|
||||
assert.equal(h.store.getState().conversation, null)
|
||||
assert.equal(h.connectCount, 0)
|
||||
})
|
||||
|
||||
test("retry reboots a failed initial load, and concurrent bootstrap is single-flight", async () => {
|
||||
let listCalls = 0
|
||||
const config = deferred()
|
||||
const h = harness({ fetchImWidgetConfig: () => config.promise,
|
||||
fetchImConversationList: async () => { if (++listCalls === 1) throw new Error("offline"); return page([conversation(1)]) } })
|
||||
h.seed({ initialized: false, isOpen: true, conversation: null, activeConversationId: 0 })
|
||||
const first = h.store.getState().bootstrap()
|
||||
assert.equal(h.store.getState().bootstrap(), first)
|
||||
config.resolve({})
|
||||
await first
|
||||
assert.equal(h.store.getState().conversation, null)
|
||||
await h.store.getState().retry()
|
||||
assert.equal(h.store.getState().conversation.id, 1)
|
||||
assert.equal(h.store.getState().error, "")
|
||||
})
|
||||
|
||||
test("new request and request switching reconnect after transport was disconnected", async () => {
|
||||
const h = harness()
|
||||
h.seed({ isOpen: true, socket: null })
|
||||
await h.store.getState().startNewConversation()
|
||||
assert.equal(h.store.getState().conversation.id, 3)
|
||||
assert.ok(h.connectCount > 0)
|
||||
const connects = h.connectCount
|
||||
await h.store.getState().selectConversation(2)
|
||||
assert.ok(h.connectCount > connects)
|
||||
})
|
||||
|
||||
test("list refresh cannot overwrite a delivered send that happened after its snapshot", async () => {
|
||||
const list = deferred()
|
||||
const h = harness({ fetchImConversationList: () => list.promise,
|
||||
sendImMessage: async () => message(10, 1) })
|
||||
const pending = h.store.getState().refreshConversations()
|
||||
await h.store.getState().handleSendMessage("hello")
|
||||
list.resolve(page([conversation(1), conversation(2)]))
|
||||
await pending
|
||||
assert.equal(h.store.getState().conversation.lastMessageId, 10)
|
||||
})
|
||||
|
||||
test("image upload completes into captured draft even after selecting another request", async () => {
|
||||
const upload = deferred()
|
||||
const h = harness({ uploadImImage: () => upload.promise })
|
||||
const pending = h.store.getState().uploadMessageImage({})
|
||||
await h.store.getState().selectConversation(2)
|
||||
const asset = { assetId: "image-1" }
|
||||
upload.resolve(asset)
|
||||
assert.equal(await pending, asset)
|
||||
})
|
||||
|
||||
test("changed identity clears private state while closed, before reopening", async () => {
|
||||
const h = harness()
|
||||
h.seed({ messages: [message(10, 1)], isOpen: false })
|
||||
h.setIdentity("visitor-2")
|
||||
await h.store.getState().bootstrap()
|
||||
assert.equal(h.store.getState().conversation, null)
|
||||
assert.equal(h.store.getState().conversations.length, 0)
|
||||
assert.equal(h.store.getState().messages.length, 0)
|
||||
assert.equal(h.store.getState().identityScope, "visitor-2")
|
||||
})
|
||||
|
||||
test("reselected attachment reuses upload and logical send key after uncertain failure", async () => {
|
||||
const sent = []
|
||||
let uploads = 0
|
||||
const h = harness({ uploadImAttachment: async () => { uploads++; return { assetId: "asset-1", filename: "attachment.txt" } },
|
||||
sendImMessage: async (payload) => { sent.push(payload); if (sent.length === 1) throw new Error("ack lost"); return message(10, 1) } })
|
||||
await assert.rejects(h.store.getState().sendAttachment({ content: "same file" }), /ack lost/)
|
||||
await h.store.getState().sendAttachment({ content: "same file" })
|
||||
assert.equal(uploads, 1)
|
||||
assert.equal(sent[0].clientMsgId, sent[1].clientMsgId)
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
10
web/lib/support-availability-i18n.ts
Normal file
10
web/lib/support-availability-i18n.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { ServiceStatus } from "@/lib/generated/enums"
|
||||
import type { AvailabilityReason } from "@/lib/api/support-availability"
|
||||
type Translate = (key: string, values?: Record<string, string | number>) => string
|
||||
const statusKeys: Record<ServiceStatus, string> = {
|
||||
[ServiceStatus.Idle]: "ready", [ServiceStatus.Busy]: "busy", [ServiceStatus.Break]: "break", [ServiceStatus.Unavailable]: "unavailable", [ServiceStatus.Vacation]: "vacation",
|
||||
}
|
||||
export function availabilityStatusLabel(status: ServiceStatus, t: Translate) { return t(`supportAvailability.status.${statusKeys[status] ?? "unknown"}`) }
|
||||
export function availabilityStatusOptions(t: Translate, withVacation = true) { return Object.values(ServiceStatus).filter((value): value is ServiceStatus => typeof value === "number" && (withVacation || value !== ServiceStatus.Vacation)).map(value => ({ value: String(value), label: availabilityStatusLabel(value, t) })) }
|
||||
const reasonKeys = new Set<AvailabilityReason>(["eligible", "profile_disabled", "user_disabled", "team_disabled", "outside_hours", "offline", "busy", "break", "unavailable", "vacation", "auto_assign_disabled", "capacity"])
|
||||
export function availabilityReasonLabel(reason: AvailabilityReason, t: Translate) { return t(`supportAvailability.reason.${reasonKeys.has(reason) ? reason : "unknown"}`) }
|
||||
53
web/lib/support-availability.locale-edge.test.mjs
Normal file
53
web/lib/support-availability.locale-edge.test.mjs
Normal file
@@ -0,0 +1,53 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { readFileSync } from "node:fs"
|
||||
import test from "node:test"
|
||||
import { loadTypeScriptModule } from "./test-support/load-typescript-module.mjs"
|
||||
|
||||
const helpers = loadTypeScriptModule(new URL("./support-availability.ts", import.meta.url), {}, { Date, Intl, Set })
|
||||
|
||||
function inTimezone(timezone, run) {
|
||||
const previous = process.env.TZ
|
||||
process.env.TZ = timezone
|
||||
try { run() } finally {
|
||||
if (previous === undefined) delete process.env.TZ
|
||||
else process.env.TZ = previous
|
||||
}
|
||||
}
|
||||
|
||||
test("European vacation wall times reject the spring gap and preserve both autumn occurrences", () => {
|
||||
inTimezone("Europe/Berlin", () => {
|
||||
assert.equal(helpers.localDateTimeToISO("2026-03-29T02:30"), null)
|
||||
assert.deepEqual(Array.from(helpers.localDateTimeCandidates("2026-10-25T02:30")), [
|
||||
"2026-10-25T00:30:00.000Z", "2026-10-25T01:30:00.000Z",
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
test("half-hour DST overlaps expose both instants rather than assuming an hour", () => {
|
||||
inTimezone("Australia/Lord_Howe", () => {
|
||||
const values = Array.from(helpers.localDateTimeCandidates("2026-04-05T01:45"))
|
||||
assert.deepEqual(values, ["2026-04-04T14:45:00.000Z", "2026-04-04T15:15:00.000Z"])
|
||||
assert.equal(helpers.selectedLocalDateTimeISO("2026-04-05T01:45", values[1]), values[1])
|
||||
})
|
||||
})
|
||||
|
||||
function flatten(value, prefix = "") {
|
||||
return Object.fromEntries(Object.entries(value).flatMap(([key, item]) => (
|
||||
item && typeof item === "object" ? Object.entries(flatten(item, `${prefix}${key}.`)) : [[`${prefix}${key}`, item]]
|
||||
)))
|
||||
}
|
||||
|
||||
test("every availability translation and interpolation remains complete across Russian, English and Chinese", () => {
|
||||
const catalogs = Object.fromEntries(["en-US", "ru-RU", "zh-CN"].map(locale => [
|
||||
locale, flatten(JSON.parse(readFileSync(new URL(`../messages/${locale}.json`, import.meta.url), "utf8")).supportAvailability),
|
||||
]))
|
||||
const reference = catalogs["en-US"]
|
||||
for (const [locale, catalog] of Object.entries(catalogs)) {
|
||||
assert.deepEqual(Object.keys(catalog).sort(), Object.keys(reference).sort(), `${locale} availability keys`)
|
||||
for (const [key, text] of Object.entries(catalog)) {
|
||||
assert.equal(typeof text, "string", `${locale}: ${key}`)
|
||||
assert.ok(text.trim(), `${locale}: ${key} is empty`)
|
||||
assert.deepEqual((text.match(/\{[^}]+\}/g) ?? []).sort(), (reference[key].match(/\{[^}]+\}/g) ?? []).sort(), `${locale}: ${key} interpolation`)
|
||||
}
|
||||
}
|
||||
})
|
||||
134
web/lib/support-availability.test.mjs
Normal file
134
web/lib/support-availability.test.mjs
Normal file
@@ -0,0 +1,134 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { readFile } from "node:fs/promises"
|
||||
import test from "node:test"
|
||||
import vm from "node:vm"
|
||||
import ts from "typescript"
|
||||
|
||||
async function load(path, imports = {}, extras = {}) {
|
||||
const source = await readFile(new URL(path, import.meta.url), "utf8")
|
||||
const compiled = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2020, module: ts.ModuleKind.CommonJS, jsx: ts.JsxEmit.ReactJSX } })
|
||||
const loaded = { exports: {} }
|
||||
vm.runInNewContext(compiled.outputText, { exports: loaded.exports, module: loaded, require: name => { assert.ok(name in imports, `unexpected import ${name}`); return imports[name] }, Intl, Date, Set, AbortController, setTimeout, clearTimeout, process, ...extras })
|
||||
return loaded.exports
|
||||
}
|
||||
const helpers = await load("./support-availability.ts")
|
||||
const base = { teamId: 5, mode: "recurring", timezone: "Europe/Moscow", effectiveTimezone: "Europe/Moscow", deploymentTimezone: "UTC", weekly: [], exceptions: [] }
|
||||
|
||||
test("calendar accepts split, overnight and full-day midnight intervals", () => {
|
||||
for (const weekly of [
|
||||
[{ weekday: 1, start: "09:00", end: "12:00" }, { weekday: 1, start: "13:00", end: "18:00" }],
|
||||
[{ weekday: 7, start: "22:00", end: "02:00" }],
|
||||
[{ weekday: 1, start: "00:00", end: "24:00" }],
|
||||
]) assert.equal(helpers.calendarValidationKey({ ...base, weekly }), null)
|
||||
})
|
||||
test("invalid IANA zones, offsets, equal intervals and invalid midnight starts fail", () => {
|
||||
for (const timezone of ["Mars/Olympus", "+03:00", "Local"]) assert.equal(helpers.calendarValidationKey({ ...base, timezone }), "supportAvailability.invalidTimezone")
|
||||
assert.equal(helpers.calendarValidationKey({ ...base, teamId: 0, timezone: "" }), "supportAvailability.invalidTimezone")
|
||||
assert.equal(helpers.calendarValidationKey({ ...base, timezone: "" }), null)
|
||||
for (const [start, end] of [["08:00", "08:00"], ["24:00", "08:00"], ["09:00", "24:01"]]) assert.equal(helpers.calendarValidationKey({ ...base, weekly: [{ weekday: 1, start, end }] }), "supportAvailability.invalidHours")
|
||||
})
|
||||
test("invalid, duplicate and leap exception dates are checked", () => {
|
||||
for (const date of ["2026-02-29", "2026-04-31", "invalid"]) assert.equal(helpers.calendarValidationKey({ ...base, exceptions: [{ date, closed: true, periods: [] }] }), "supportAvailability.invalidExceptionDate")
|
||||
const leap = { date: "2028-02-29", closed: true, periods: [] }
|
||||
assert.equal(helpers.calendarValidationKey({ ...base, exceptions: [leap] }), null)
|
||||
assert.equal(helpers.calendarValidationKey({ ...base, exceptions: [leap, leap] }), "supportAvailability.invalidExceptionDate")
|
||||
})
|
||||
test("closing a holiday normalizes outgoing periods without discarding the editing draft", () => {
|
||||
const calendar = { ...base, exceptions: [{ date: "2026-12-25", closed: true, periods: [{ start: "00:00", end: "24:00" }] }] }
|
||||
const payload = helpers.supportCalendarPayload(calendar)
|
||||
assert.equal(payload.exceptions[0].periods.length, 0)
|
||||
assert.equal(calendar.exceptions[0].periods.length, 1)
|
||||
calendar.exceptions[0].closed = false
|
||||
assert.equal(helpers.supportCalendarPayload(calendar).exceptions[0].periods[0].end, "24:00")
|
||||
})
|
||||
test("vacation validates absolute interval and rejects DST gap without shifting", () => {
|
||||
const previous = process.env.TZ
|
||||
process.env.TZ = "America/New_York"
|
||||
try {
|
||||
assert.equal(helpers.localDateTimeToISO("2026-03-08T02:30"), null)
|
||||
assert.equal(helpers.localDateTimeToISO("2026-03-08T03:30"), "2026-03-08T07:30:00.000Z")
|
||||
assert.equal(helpers.vacationValidationKey("2026-10-16T10:00", "2026-10-16T09:00"), "supportAvailability.invalidVacation")
|
||||
assert.equal(helpers.vacationValidationKey("2026-10-16T10:00", "2026-10-16T11:00"), null)
|
||||
} finally { process.env.TZ = previous }
|
||||
})
|
||||
test("DST overlap exposes both exact instants and preserves existing later occurrence", () => {
|
||||
const previous = process.env.TZ
|
||||
process.env.TZ = "America/New_York"
|
||||
try {
|
||||
const values = Array.from(helpers.localDateTimeCandidates("2026-11-01T01:30"))
|
||||
assert.deepEqual(values, ["2026-11-01T05:30:00.000Z", "2026-11-01T06:30:00.000Z"])
|
||||
assert.equal(helpers.selectedLocalDateTimeISO("2026-11-01T01:30", values[1]), values[1])
|
||||
assert.equal(helpers.selectedLocalDateTimeISO("2026-11-01T01:30", "stale"), values[0])
|
||||
} finally { process.env.TZ = previous }
|
||||
})
|
||||
test("preview renders calendar zone and explicitly uses UTC for opaque legacy process zones", () => {
|
||||
assert.match(helpers.formatAvailabilityTime("2026-10-16T12:00:00Z", "Europe/Moscow", "en-US"), /03:00|15:00/)
|
||||
assert.match(helpers.formatAvailabilityTime("2026-10-16T12:00:00Z", "Local", "en-US"), /UTC/)
|
||||
})
|
||||
|
||||
async function apiWith(request, extras = {}) {
|
||||
return load("./api/support-availability.ts", {
|
||||
"@/lib/api/client": { request },
|
||||
"@/lib/support-availability": helpers,
|
||||
"@/lib/sdk/runtime-config": { readSupportChatRuntimeConfig: () => ({ channelId: "channel-a", baseUrl: "https://support.example/", userToken: "must-not-send" }) },
|
||||
}, extras)
|
||||
}
|
||||
test("public status is channel-scoped, anonymous and uses shared request without private credentials", async () => {
|
||||
let call
|
||||
const api = await apiWith(async (url, options) => { call = { url, options }; return { available: false, reason: "temporarily_absent", timezone: "UTC" } })
|
||||
const response = await api.fetchPublicSupportStatus()
|
||||
assert.equal(call.url, "/api/support/status")
|
||||
assert.equal(call.options.skipAuth, true)
|
||||
assert.equal(call.options.baseUrl, "https://support.example")
|
||||
assert.equal(call.options.headers["X-Channel-Id"], "channel-a")
|
||||
assert.equal("Authorization" in call.options.headers, false)
|
||||
assert.equal(response.available, false)
|
||||
})
|
||||
test("assignment candidates use captured conversation and trust server eligibility, not idle alone", async () => {
|
||||
let path
|
||||
const api = await apiWith(async url => { path = url; return [{ online: false, serviceStatus: 0, eligible: false, reason: "offline" }] })
|
||||
const items = await api.fetchAvailabilityAgents(19)
|
||||
assert.equal(path, "/api/dashboard/support_availability/agents?conversationId=19")
|
||||
assert.equal(items[0].eligible, false)
|
||||
})
|
||||
test("calendar save and preview normalize closed periods identically and retain explicit mode", async () => {
|
||||
const calls = []
|
||||
const api = await apiWith(async (url, options) => { calls.push({ url, body: JSON.parse(options.body) }); return base })
|
||||
const draft = { ...base, mode: "legacy", exceptions: [{ date: "2026-12-25", closed: true, periods: [{ start: "09:00", end: "17:00" }] }] }
|
||||
await api.updateSupportCalendar(draft)
|
||||
await api.previewSupportCalendar(draft, "2026-12-25T12:00:00Z")
|
||||
assert.equal(calls[0].body.exceptions[0].periods.length, 0)
|
||||
assert.equal(calls[0].body.mode, "legacy")
|
||||
assert.equal(calls[1].body.calendar.exceptions[0].periods.length, 0)
|
||||
assert.equal(calls[1].body.at, "2026-12-25T12:00:00Z")
|
||||
assert.equal(draft.exceptions[0].periods.length, 1)
|
||||
})
|
||||
test("a hung availability read has a bounded deadline and cleans up timers", async () => {
|
||||
let timeout, cleared = false, signal
|
||||
const api = await apiWith(async (_url, options) => new Promise((_, reject) => {
|
||||
signal = options.signal
|
||||
signal.addEventListener("abort", () => reject(new Error("aborted")))
|
||||
}), { setTimeout(fn, delay) { assert.equal(delay, 10000); timeout = fn; return 1 }, clearTimeout(id) { assert.equal(id, 1); cleared = true } })
|
||||
const pending = api.fetchMyAvailability()
|
||||
timeout()
|
||||
await assert.rejects(pending, /aborted/)
|
||||
assert.equal(signal.aborted, true)
|
||||
assert.equal(cleared, true)
|
||||
})
|
||||
test("unmount/account-scope cancellation aborts an in-flight availability read", async () => {
|
||||
const controller = new AbortController()
|
||||
const api = await apiWith(async (_url, options) => new Promise((_, reject) => options.signal.addEventListener("abort", () => reject(new Error("aborted")))))
|
||||
const pending = api.fetchAvailabilityAgents(undefined, controller.signal)
|
||||
controller.abort()
|
||||
await assert.rejects(pending, /aborted/)
|
||||
})
|
||||
test("all generated manual statuses and blocking reasons have complete translations", async () => {
|
||||
const enums = await load("./generated/enums.ts")
|
||||
const mapping = await load("./support-availability-i18n.ts", { "@/lib/generated/enums": enums })
|
||||
for (const locale of ["en-US", "ru-RU", "zh-CN"]) {
|
||||
const catalog = JSON.parse(await readFile(new URL(`../messages/${locale}.json`, import.meta.url), "utf8"))
|
||||
const t = key => { const value = key.split(".").reduce((value, part) => value?.[part], catalog); assert.equal(typeof value, "string", `${locale}: ${key}`); return value }
|
||||
assert.equal(mapping.availabilityStatusOptions(t).length, 5)
|
||||
for (const reason of ["eligible", "profile_disabled", "user_disabled", "team_disabled", "outside_hours", "offline", "busy", "break", "unavailable", "vacation", "auto_assign_disabled", "capacity"]) mapping.availabilityReasonLabel(reason, t)
|
||||
}
|
||||
})
|
||||
70
web/lib/support-availability.ts
Normal file
70
web/lib/support-availability.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import type { SupportCalendar } from "@/lib/api/support-availability"
|
||||
|
||||
export function isValidIanaTimezone(value: string): boolean {
|
||||
// Intl also accepts offset identifiers on some engines; the server expects an IANA name.
|
||||
if (!value || /^[+-]/.test(value)) return false
|
||||
try { new Intl.DateTimeFormat("en-US", { timeZone: value }).format(); return true } catch { return false }
|
||||
}
|
||||
export function calendarValidationKey(calendar: SupportCalendar): string | null {
|
||||
if ((!calendar.timezone && calendar.teamId === 0) || (calendar.timezone && !isValidIanaTimezone(calendar.timezone))) return "supportAvailability.invalidTimezone"
|
||||
if (calendar.mode === "legacy") return null
|
||||
const validTime = (value: string) => /^(?:[01]\d|2[0-3]):[0-5]\d$/.test(value)
|
||||
const validPeriod = (period: { start: string; end: string }) => validTime(period.start) && (period.end === "24:00" || validTime(period.end)) && period.start !== period.end
|
||||
if (calendar.weekly.some(p => !Number.isInteger(p.weekday) || p.weekday < 1 || p.weekday > 7 || !validPeriod(p))) return "supportAvailability.invalidHours"
|
||||
const dates = new Set<string>()
|
||||
for (const day of calendar.exceptions) {
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(day.date) || !Number.isFinite(Date.parse(`${day.date}T00:00:00Z`)) || new Date(`${day.date}T00:00:00Z`).toISOString().slice(0, 10) !== day.date || dates.has(day.date)) return "supportAvailability.invalidExceptionDate"
|
||||
dates.add(day.date)
|
||||
if (!day.closed && (day.periods.length === 0 || day.periods.some(p => !validPeriod(p)))) return "supportAvailability.invalidHours"
|
||||
}
|
||||
return null
|
||||
}
|
||||
export function toLocalDateTime(value?: string): string {
|
||||
if (!value) return ""
|
||||
const date = new Date(value)
|
||||
if (!Number.isFinite(date.getTime())) return ""
|
||||
const pad = (n: number) => String(n).padStart(2, "0")
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`
|
||||
}
|
||||
export function localDateTimeToISO(value: string): string | null {
|
||||
if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/.test(value)) return null
|
||||
const date = new Date(value)
|
||||
// Reject invalid dates and the DST gap rather than silently shifting the user's input.
|
||||
if (!Number.isFinite(date.getTime()) || toLocalDateTime(date.toISOString()) !== value) return null
|
||||
return date.toISOString()
|
||||
}
|
||||
export function vacationValidationKey(start: string, end: string): string | null {
|
||||
const first = localDateTimeToISO(start), last = localDateTimeToISO(end)
|
||||
return !first || !last || Date.parse(first) >= Date.parse(last) ? "supportAvailability.invalidVacation" : null
|
||||
}
|
||||
export function formatAvailabilityTime(value: string | undefined, timezone: string, locale: string): string {
|
||||
if (!value) return ""
|
||||
const date = new Date(value)
|
||||
if (!Number.isFinite(date.getTime())) return ""
|
||||
// Legacy process zones can be named "Local". Show the actual UTC instant, not an invented offset.
|
||||
if (!isValidIanaTimezone(timezone)) timezone = "UTC"
|
||||
// This feature explicitly previews a configured timezone, unlike the default app timestamp formatter.
|
||||
return new Intl.DateTimeFormat(locale, { timeZone: timezone, year: "numeric", month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", timeZoneName: "short" }).format(date)
|
||||
}
|
||||
|
||||
// Explicit alternatives for repeated local wall-clock times during a DST overlap.
|
||||
export function localDateTimeCandidates(value: string): string[] {
|
||||
const first = localDateTimeToISO(value)
|
||||
if (!first) return []
|
||||
const result = new Set([first])
|
||||
const timestamp = Date.parse(first)
|
||||
for (let minutes = -180; minutes <= 180; minutes += 15) {
|
||||
const candidate = new Date(timestamp + minutes * 60000).toISOString()
|
||||
if (toLocalDateTime(candidate) === value) result.add(candidate)
|
||||
}
|
||||
return [...result].sort()
|
||||
}
|
||||
export function selectedLocalDateTimeISO(value: string, occurrence: string): string | null {
|
||||
const candidates = localDateTimeCandidates(value)
|
||||
return candidates.includes(occurrence) ? occurrence : candidates[0] ?? null
|
||||
}
|
||||
|
||||
// Closing an exception ignores its draft periods but does not erase them while editing.
|
||||
export function supportCalendarPayload(calendar: SupportCalendar): SupportCalendar {
|
||||
return { ...calendar, timezone: calendar.timezone.trim(), weekly: calendar.weekly.map(period => ({ ...period })), exceptions: calendar.exceptions.map(day => ({ ...day, periods: day.closed ? [] : day.periods.map(period => ({ ...period })) })) }
|
||||
}
|
||||
28
web/lib/test-support/load-typescript-module.mjs
Normal file
28
web/lib/test-support/load-typescript-module.mjs
Normal file
@@ -0,0 +1,28 @@
|
||||
import { readFileSync } from "node:fs"
|
||||
import vm from "node:vm"
|
||||
import ts from "typescript"
|
||||
|
||||
/** Runs the real module with explicitly supplied offline transports/dependencies. */
|
||||
export function loadTypeScriptModule(url, dependencies, globals = {}) {
|
||||
const { outputText } = ts.transpileModule(readFileSync(url, "utf8"), {
|
||||
compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS, jsx: ts.JsxEmit.ReactJSX },
|
||||
fileName: url.pathname,
|
||||
})
|
||||
const moduleContext = { exports: {} }
|
||||
const require = (name) => {
|
||||
if (!Object.hasOwn(dependencies, name)) throw new Error(`Unmocked dependency: ${name}`)
|
||||
return dependencies[name]
|
||||
}
|
||||
vm.runInNewContext(outputText, { module: moduleContext, exports: moduleContext.exports, require, ...globals }, { filename: url.pathname })
|
||||
return moduleContext.exports
|
||||
}
|
||||
|
||||
export function deferred() {
|
||||
let resolve
|
||||
let reject
|
||||
const promise = new Promise((resolvePromise, rejectPromise) => {
|
||||
resolve = resolvePromise
|
||||
reject = rejectPromise
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
Reference in New Issue
Block a user