From 010e04f9d70a726bfb318b95ac9dc775933e6be7 Mon Sep 17 00:00:00 2001 From: Brent Rager Date: Tue, 25 Aug 2026 22:40:29 -0400 Subject: [PATCH] SMOODEV-3057: carry the OTP proof on the resume probe, before clearSession eats it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The identity-scoped resume (SMOODEV-3066) lets a CRM-linked participant match when the caller proves the same contact, via optional verifiedSessionId + email on the probe. Both fields already sit in the widget store. The ordering is the part that bites: clearSession() drops the session-scoped OTP proof by design, and both probe sites (dead pointer, dead-session recovery) call it before probing — so reading the proof afterwards would always find nothing, silently, on exactly the visitors the server change exists to help. Read first. chat-ws parses this body as an untyped JSON value, so the new fields are ignored by the wrapper running today and this can land before the server half. Co-Authored-By: Claude Opus 5 (1M context) --- .changeset/olive-jars-repeat.md | 16 ++++++++++++ src/conversation.test.ts | 43 +++++++++++++++++++++++++++++++++ src/conversation.ts | 29 +++++++++++++++++++--- 3 files changed, 84 insertions(+), 4 deletions(-) create mode 100644 .changeset/olive-jars-repeat.md diff --git a/.changeset/olive-jars-repeat.md b/.changeset/olive-jars-repeat.md new file mode 100644 index 0000000..0bfbe70 --- /dev/null +++ b/.changeset/olive-jars-repeat.md @@ -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. diff --git a/src/conversation.test.ts b/src/conversation.test.ts index 9670c37..0c60ee8 100644 --- a/src/conversation.test.ts +++ b/src/conversation.test.ts @@ -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(); diff --git a/src/conversation.ts b/src/conversation.ts index 8a7d202..1b18524 100644 --- a/src/conversation.ts +++ b/src/conversation.ts @@ -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 { if (this.client) return; @@ -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); @@ -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 @@ -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 { + private async resumeByFingerprint(proof: { verifiedSessionId: string; email: string } | null = null): Promise { 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 @@ -1003,6 +1023,7 @@ export class ConversationController { */ private async recreateSession(): Promise { this.pointerState = 'recovery'; + const proof = this.otpProof(); this.store.getState().clearSession(); this.sessionId = null; this.conversationId = null; @@ -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;