Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .changeset/olive-jars-repeat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
'@smooai/chat-widget': patch
---

Carry the browser's OTP proof on the fingerprint resume probe (SMOODEV-3066)

The probe now sends `verifiedSessionId` + `email` when the widget holds an OTP
proof, so the server-side identity-scoped resume can allow a CRM-linked match
for the *same* contact. Both fields are optional and only meaningful together;
`chat-ws` parses this body as an untyped JSON value, so a wrapper without the
server half ignores them.

Read before `clearSession()`, which drops the session-scoped proof by design.
Both probe sites β€” the dead pointer and the dead-session recovery β€” clear before
probing, so reading afterwards would always find nothing, silently, on exactly
the visitors the server change exists to help.
43 changes: 43 additions & 0 deletions src/conversation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -705,6 +705,49 @@ describe('ConversationController β€” resume probe reason (SMOODEV-3057 observabi
});
afterEach(() => localStorage.clear());

it('carries the OTP proof on the probe β€” including after a dead pointer clears it (SMOODEV-3066)', async () => {
// clearSession() drops verifiedEmail by design, and the dead-pointer path
// calls it before probing. Read the proof first or a CRM-linked visitor can
// never resume.
const seed = createWidgetStore(AGENT);
seed.getState().setSessionId('sess-dead');
seed.getState().setVerifiedEmail('ada@example.com', 'sess-verified');
MockSocket.onFrame = (frame, reply) => {
const requestId = frame.requestId;
if (frame.action === 'get_session') {
reply({ type: 'immediate_response', requestId, status: 200, data: { sessionId: 'sess-dead', status: 'ended', agentId: AGENT } });
} else {
defaultOnFrame(frame, reply);
}
};
fetchRouter = () => ({ json: { resumable: false, reason: 'identity_required' } });

const { controller } = makeController();
await controller.connect();

const probe = fetchCalls.find((c) => c.path === '/internal/resume-by-fingerprint');
expect(probe?.body.verifiedSessionId).toBe('sess-verified');
expect(probe?.body.email).toBe('ada@example.com');
});

it('sends neither identity field when there is no OTP proof', async () => {
fetchRouter = () => ({ json: { resumable: false } });
const { controller } = makeController();
await controller.connect();
const probe = fetchCalls.find((c) => c.path === '/internal/resume-by-fingerprint');
expect(probe?.body.verifiedSessionId).toBeUndefined();
expect(probe?.body.email).toBeUndefined();
});

it('treats an UNKNOWN reason as start-fresh, so the server can add names without a client release', async () => {
fetchRouter = () => ({ json: { resumable: false, reason: 'some_reason_invented_next_year' } });
const { controller, store } = makeController();
await controller.connect();
expect(controller.lastResumeReason).toBe('some_reason_invented_next_year');
expect(store.getState().sessionId).toBe('sess-new');
expect(controller.connectionStatus).toBe('ready');
});

it("reports the SERVER's reason verbatim when the response carries one", async () => {
fetchRouter = () => ({ json: { resumable: false, reason: 'crm_linked' } });
const { controller } = makeController();
Expand Down
29 changes: 25 additions & 4 deletions src/conversation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -666,6 +666,19 @@ export class ConversationController {
return null;
}

/**
* The OTP proof this browser holds β€” the session it was proven on, plus the
* email β€” or null. Unlike {@link verifiedEmailForSession} this is NOT bound to
* the session being resumed: the fingerprint probe sends it so the server can
* re-read its own verification row and allow a CRM-linked match for the SAME
* contact (SMOODEV-3066). The server never trusts the email; it re-verifies,
* and its record expires 30 min after the OTP.
*/
private otpProof(): { verifiedSessionId: string; email: string } | null {
const { verifiedEmail, verifiedEmailSessionId } = this.store.getState();
return verifiedEmail && verifiedEmailSessionId ? { verifiedSessionId: verifiedEmailSessionId, email: verifiedEmail } : null;
}

/** Lazily open the WS client (default transport). Idempotent within a connect. */
private async ensureClient(): Promise<void> {
if (this.client) return;
Expand Down Expand Up @@ -729,6 +742,10 @@ export class ConversationController {
// attempt, fall straight through to creating a fresh session.
if (!this.resumeAttempted) {
this.resumeAttempted = true;
// Read the OTP proof BEFORE any clearSession() β€” that call drops it
// (by design: it is session-scoped), which would silently strip the
// one thing that lets a CRM-linked visitor resume.
const proof = this.otpProof();
const persistedSessionId = this.store.getState().sessionId;
if (persistedSessionId) {
const resumed = await this.tryResume(persistedSessionId);
Expand All @@ -746,7 +763,7 @@ export class ConversationController {
// conversation even when a resumable one existed β€” one visitor,
// several inbox rows (SMOODEV-3057). The wrapper is authoritative
// about what is resumable; a stale local pointer is not.
const { sessionId: fpSessionId } = await this.resumeByFingerprint();
const { sessionId: fpSessionId } = await this.resumeByFingerprint(proof);
// Re-probing the id we just failed on is fine and sometimes the
// point: the wrapper primes the operator registry, so a resume that
// failed on a registry miss can succeed on this second attempt. An
Expand Down Expand Up @@ -824,9 +841,12 @@ export class ConversationController {
* bare `null` out of a bare `catch {}`, which is why SMOODEV-3057 went
* undiagnosed for weeks.
*/
private async resumeByFingerprint(): Promise<ResumeProbeResult> {
private async resumeByFingerprint(proof: { verifiedSessionId: string; email: string } | null = null): Promise<ResumeProbeResult> {
try {
const json = await this.postInternal('/internal/resume-by-fingerprint', { browserFingerprint: this.fingerprint() });
// `verifiedSessionId` + `email` are optional and meaningful only TOGETHER;
// an older wrapper parses the body as an untyped JSON value and ignores
// both, so sending them is safe before the server half lands.
const json = await this.postInternal('/internal/resume-by-fingerprint', { browserFingerprint: this.fingerprint(), ...(proof ?? {}) });
// Tolerate BOTH response shapes. The server-side half of this fix makes
// `{resumable:false}` carry a `reason`; wrappers without it (every one
// deployed today) send none, so derive a local fallback rather than
Expand Down Expand Up @@ -1003,6 +1023,7 @@ export class ConversationController {
*/
private async recreateSession(): Promise<void> {
this.pointerState = 'recovery';
const proof = this.otpProof();
this.store.getState().clearSession();
this.sessionId = null;
this.conversationId = null;
Expand All @@ -1013,7 +1034,7 @@ export class ConversationController {
// primed β€” instead of the visitor's conversation splitting in two mid-chat.
// Bounded: send() retries this whole path exactly once, so a server that
// keeps saying not-found still cannot spin sessions.
const { sessionId: fpSessionId } = await this.resumeByFingerprint();
const { sessionId: fpSessionId } = await this.resumeByFingerprint(proof);
if (fpSessionId && (await this.tryResume(fpSessionId))) {
this.store.getState().setSessionId(fpSessionId);
return;
Expand Down
Loading