fix: harden identity, live access, drafts and setup (review only)

Source-reviewed hardening changes and regression tests. UNBUILT / UNTESTED: not a deployable release. Go formatting, compilation, automated regression tests, full frontend validation and staging acceptance remain required. See docs/hardening/VALIDATION.md. Main remains the original clean snapshot; no upstream Git history imported.
This commit is contained in:
2026-10-16 12:15:20 +00:00
parent e57610a27c
commit 63e0457886
86 changed files with 5174 additions and 1929 deletions

View File

@@ -1,3 +1,4 @@
// Modified in the independent hardening fork; see docs/hardening/README.md.
"use client"
import {
@@ -113,12 +114,14 @@ function SupportChatPanelContent() {
useSupportChatSystemTheme()
const messageListRef = useRef<SupportChatMessageListHandle | null>(null)
const mountedRef = useRef(false)
const [isEmbedded, setIsEmbedded] = useState(false)
const [isCloseDialogOpen, setIsCloseDialogOpen] = useState(false)
const [isClosingConversation, setIsClosingConversation] = useState(false)
const [isNewRequestDialogOpen, setIsNewRequestDialogOpen] = useState(false)
const {
identityScope,
title,
subtitle,
themeColor,
@@ -129,6 +132,8 @@ function SupportChatPanelContent() {
loadOlderMessages,
status,
error,
sending,
uploadingAsset,
isOpen,
isVisible,
setIsOpen,
@@ -144,6 +149,7 @@ function SupportChatPanelContent() {
setConversationSatisfaction,
} = useSupportChatStore(
useShallow((state) => ({
identityScope: state.identityScope,
title: state.title,
subtitle: state.subtitle,
themeColor: state.themeColor,
@@ -154,6 +160,8 @@ function SupportChatPanelContent() {
loadOlderMessages: state.loadOlderMessages,
status: state.status,
error: state.error,
sending: state.sending,
uploadingAsset: state.uploadingAsset,
isOpen: state.isOpen,
isVisible: state.isVisible,
setIsOpen: state.setIsOpen,
@@ -182,6 +190,7 @@ function SupportChatPanelContent() {
if (document.visibilityState !== "visible") {
return
}
if (useSupportChatStore.getState().conversation?.id !== conversation.id) return
void markConversationRead().catch((readError) => {
console.error("Failed to mark support chat conversation read", readError)
})
@@ -190,9 +199,9 @@ function SupportChatPanelContent() {
useEffect(() => {
return bindSupportHostBridge({
onInit: () => {
if (useSupportChatStore.getState().isOpen) {
bootstrap()
}
// Clear a previous visitor's private state even while the host keeps
// this frame hidden; visibility must never race identity replacement.
void bootstrap()
},
onOpen: () => {
setIsOpen(true)
@@ -205,14 +214,16 @@ function SupportChatPanelContent() {
}, [bootstrap, setIsOpen, setIsVisible])
useEffect(() => {
bootstrap()
void bootstrap()
}, [isOpen, bootstrap])
useEffect(() => {
mountedRef.current = true
return () => {
if (!isOpen) {
disconnectSocket()
}
mountedRef.current = false
disconnectSocket()
}
}, [isOpen, bootstrap, disconnectSocket])
}, [disconnectSocket])
useEffect(() => {
maybeMarkConversationRead()
@@ -236,9 +247,21 @@ function SupportChatPanelContent() {
}
}, [maybeMarkConversationRead])
function requireDisplayedConversation() {
const current = useSupportChatStore.getState()
if (!conversation?.id || current.identityScope !== identityScope || current.conversation?.id !== conversation.id) {
throw new Error(t("supportChat.sendUnavailable"))
}
}
async function handleSend(content: string) {
requireDisplayedConversation()
const submittedConversationId = conversation?.id
await handleSendMessage(content)
messageListRef.current?.scrollToBottom()
const current = useSupportChatStore.getState()
if (current.identityScope === identityScope && current.conversation?.id === submittedConversationId) {
messageListRef.current?.scrollToBottom()
}
}
function handleCloseWindow() {
@@ -246,7 +269,8 @@ function SupportChatPanelContent() {
if (isEmbedded) {
requestSupportHostMinimize()
} else {
window.location.replace(getStandaloneClosedUrl())
setIsOpen(false)
window.location.replace(getStandaloneClosedUrl(window.location.search))
}
}
@@ -257,6 +281,7 @@ function SupportChatPanelContent() {
setIsClosingConversation(true)
try {
if (conversation?.id) {
requireDisplayedConversation()
await closeConversation()
}
setIsCloseDialogOpen(false)
@@ -351,15 +376,29 @@ function SupportChatPanelContent() {
conversationId={conversation.id}
customerToken={getCustomerSessionToken()}
initialSatisfaction={conversation.satisfaction}
onSubmitted={setConversationSatisfaction}
onSubmitted={(satisfaction) => {
if (mountedRef.current && useSupportChatStore.getState().identityScope === identityScope) {
setConversationSatisfaction(satisfaction)
}
}}
/>
</div>
) : null}
<CustomerMessageEditor
conversationId={conversation?.id}
draftScope={identityScope}
sending={sending}
uploadingAsset={uploadingAsset}
disabled={!conversation || conversation.status === 4}
onSend={handleSend}
onUploadImage={uploadMessageImage}
onSendAttachment={sendAttachment}
onUploadImage={async (file) => {
requireDisplayedConversation()
return uploadMessageImage(file)
}}
onSendAttachment={async (file) => {
requireDisplayedConversation()
await sendAttachment(file)
}}
/>
</div>
</div>

View File

@@ -1,3 +1,4 @@
// Modified in the independent hardening fork; see docs/hardening/README.md.
import assert from "node:assert/strict"
import test from "node:test"
import ts from "typescript"
@@ -14,6 +15,7 @@ async function loadCloseNavigation() {
fileName: "close-navigation.ts",
})
const sandbox = {
URLSearchParams,
exports: {},
module: { exports: {} },
}
@@ -27,3 +29,10 @@ test("standalone close navigates to the non-bootstrapping closed page", async ()
assert.equal(getStandaloneClosedUrl(), "/support/chat/closed")
})
test("dismiss and return retain the entry channel without accepting a redirect destination", async () => {
const { getStandaloneClosedUrl, getStandaloneChatUrl } = await loadCloseNavigation()
const search = "?channelId=help&externalId=visitor&returnUrl=https%3A%2F%2Fexample.org"
assert.equal(getStandaloneClosedUrl(search), `/support/chat/closed${search}`)
assert.equal(getStandaloneChatUrl(search), `/support/chat${search}`)
})

View File

@@ -1,5 +1,16 @@
// Modified in the independent hardening fork; see docs/hardening/README.md.
export const SUPPORT_CHAT_CLOSED_PATH = "/support/chat/closed"
export function getStandaloneClosedUrl() {
return SUPPORT_CHAT_CLOSED_PATH
// Keep the entry context when dismissing/returning. These are always local
// paths, never a caller-supplied redirect destination.
function querySuffix(search: string) {
return search ? `?${new URLSearchParams(search).toString()}` : ""
}
export function getStandaloneClosedUrl(search = "") {
return `${SUPPORT_CHAT_CLOSED_PATH}${querySuffix(search)}`
}
export function getStandaloneChatUrl(search = "") {
return `/support/chat${querySuffix(search)}`
}

View File

@@ -1,3 +1,5 @@
// Modified in the independent hardening fork; see docs/hardening/README.md.
// Modified for AgentDeskFixes: forward customer conversation identity and pending-send state.
"use client"
import {
@@ -6,6 +8,9 @@ import {
} from "@/components/chat/shared-message-editor"
type CustomerMessageEditorProps = {
conversationId?: number | null
draftScope?: string
sending?: boolean
disabled?: boolean
uploadingAsset?: boolean
onSend: (html: string) => Promise<void>
@@ -14,6 +19,9 @@ type CustomerMessageEditorProps = {
}
export function CustomerMessageEditor({
conversationId,
draftScope,
sending = false,
disabled = false,
uploadingAsset = false,
onSend,
@@ -23,6 +31,9 @@ export function CustomerMessageEditor({
return (
<SharedMessageEditor
variant="customer"
conversationId={conversationId}
draftScope={draftScope}
sending={sending}
disabled={disabled}
uploadingAsset={uploadingAsset}
manageLocalUploading