diff --git a/.changeset/identity-persistence-consent.md b/.changeset/identity-persistence-consent.md new file mode 100644 index 0000000..d7e94d6 --- /dev/null +++ b/.changeset/identity-persistence-consent.md @@ -0,0 +1,21 @@ +--- +'@smooai/chat-widget': minor +--- + +Identity, persistence & consent client layer (ADR-048) — same-session resume, cross-device "restore my chats", marketing consent, and a stable browser fingerprint. + +**Persisted state (Zustand).** A framework-agnostic `zustand/vanilla` store with the `persist` middleware now keeps a small per-agent blob in localStorage (`smoo-chat-widget:`): the session **pointer**, visitor identity (name/email/phone), marketing consent, a verified email, and the browser fingerprint. The **transcript is never persisted** — the smooth-operator server stays the source of truth and history is re-hydrated via `get_conversation_messages`. A `version` field drives `persist.migrate` so future shape changes upgrade old blobs in place; the storage adapter tolerates missing/locked-down localStorage (SSR, privacy mode) and never throws on boot. + +**Browser fingerprint.** Every `create_conversation_session` now carries a stable `browserFingerprint` for anonymous-visitor correlation (and server-side CRM matching). Computed once and cached in the persisted store. Rather than pull in ThumbmarkJS — tens of KB and async device-probing, too heavy for an embed whose whole point is staying out of the host's LCP/TBT budget — the fingerprint is a persisted random UUID (the exact same-browser correlator) suffixed with a small FNV hash of a few non-invasive, stable signals (UA, language, timezone, screen). No canvas/WebGL/audio probes, no network, XSS-safe. Tradeoff: weaker cross-storage matching than a full device fingerprint, deferred to the server resolver, in exchange for a tiny, transparent, privacy-light token. + +**Same-session resume.** On load, if a session pointer is persisted the widget calls `get_session`; when the session isn't `ended` it reuses the `sessionId`, replays history (`get_conversation_messages`, newest-first → reversed to chronological), skips the pre-chat form, and continues the conversation. An ended/404 session clears **only** the pointer (identity & consent survive) and starts fresh. + +**Returning-visitor resume by fingerprint (HTTP).** When there is no persisted pointer, the widget first `POST`s `/internal/resume-by-fingerprint` on the chat-ws wrapper with the browser fingerprint; if the wrapper resolves (and primes) a recent session it returns `{ resumable: true, sessionId, … }` and the widget adopts that session (then `get_session` + `get_conversation_messages` to hydrate) instead of creating a new one. `{ resumable: false }` falls through to a normal create. + +**Pre-chat form: phone + marketing consent.** The phone field is now shown by default (optional; rides session `metadata.userPhone`). Two explicit, default-unchecked consent checkboxes (email + SMS) capture marketing opt-in; ticking one stamps a `consentAt` ISO timestamp, and the consent record (`{ emailOptIn, smsOptIn, consentSource: 'chat-widget-prechat', consentAt }`) threads into the session metadata. New config flags: `collectPhone`, `collectConsent`, `allowChatRestore` (all default `true`). + +**Cross-device "Restore my chats."** An explicit footer affordance (not a mid-turn agent pause) runs the identity OTP flow over the chat-ws wrapper's HTTP routes — `POST /internal/identity/request-otp` → `verify-otp` → `resolve` — reusing the existing OTP UI. On a resolved list the visitor picks a conversation to replay (`get_session` + `get_conversation_messages`); the verified email is persisted. + +**HTTP, not WS frames.** The smooth-operator engine (1.8.0) owns the `/ws` dispatch and rejects unknown verbs, so the cross-device identity flow and fingerprint resume are `fetch()` (POST, JSON) calls to the chat-ws wrapper, with the HTTP base derived from the WS endpoint (`wss://ai.smoo.ai/ws` → `https://ai.smoo.ai`). The browser sends `Origin` automatically (origin-allowlisted server-side) and each request carries `agentId`/`agentName` plus an optional pre-auth `authContext` (`{ userId, signature, timestamp }`) from the new `authContext` config option. + +All server-supplied strings (masked destinations, conversation previews, history) are rendered via `textContent`, keeping the 0.6.0 XSS guarantees intact, and the new UI follows the Aurora-Glass styling. diff --git a/e2e/identity-persistence-mock.spec.ts b/e2e/identity-persistence-mock.spec.ts new file mode 100644 index 0000000..79e87eb --- /dev/null +++ b/e2e/identity-persistence-mock.spec.ts @@ -0,0 +1,500 @@ +/** + * Headless mock-WS coverage for the 0.7.0 identity / persistence / consent layer + * (ADR-048, SMOODEV-2129e). Loads the BUILT global bundle into a real Chromium + * page, drives the REAL shadow-DOM UI, and asserts: + * + * 1. consent checkboxes + phone produce the exact metadata payload, and the + * fingerprint rides create_conversation_session; + * 2. persist → reload → resume replays server history and reuses the sessionId; + * 3. an ended session clears the pointer and starts a fresh session; + * 4. the cross-device request → verify → resolve → replay flow. + * + * A scriptable mock WebSocket (installed via addInitScript) captures outbound + * frames on `window.__sent` and replays operator-shaped responses keyed by + * action. localStorage persists across the in-page "reload" (same origin), which + * is what the resume path reads. + */ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { expect, test } from '@playwright/test'; + +const root = fileURLToPath(new URL('..', import.meta.url)); +const GLOBAL_BUNDLE = readFileSync(`${root}/dist/chat-widget.global.js`, 'utf8'); + +const AGENT_ID = '2590dfd6-7ed5-484b-bfb4-6d83a97d5a8e'; +const ENDPOINT = 'wss://ai.smoo.ai/ws'; + +/** + * The mock WebSocket (records WS frames on window.__sent, routes via + * window.__script) PLUS a mocked fetch for the chat-ws `/internal/*` routes + * (records calls on window.__http, routes via window.__httpScript(path, body) → + * { status?, json }; default = resume:false). + */ +const MOCK_WS = ` +(() => { + window.__sent = []; + window.__http = []; + class MockWS { + constructor(url) { + this.url = url; this.readyState = 0; + this._l = { open: [], message: [], close: [], error: [] }; + setTimeout(() => { this.readyState = 1; this._emit('open', {}); }, 3); + } + addEventListener(t, fn) { (this._l[t] ||= []).push(fn); } + removeEventListener(t, fn) { const a = this._l[t]; if (a) { const i = a.indexOf(fn); if (i>=0) a.splice(i,1); } } + _emit(t, ev) { for (const fn of (this._l[t]||[]).slice()) fn(ev); } + _msg(o) { this._emit('message', { data: JSON.stringify(o) }); } + send(raw) { + let f; try { f = JSON.parse(raw); } catch { return; } + window.__sent.push(f); + (window.__script || (()=>{}))(f, (o) => this._msg(o)); + } + close() { this.readyState = 3; this._emit('close', { code: 1000, reason: '' }); } + } + MockWS.CONNECTING=0; MockWS.OPEN=1; MockWS.CLOSING=2; MockWS.CLOSED=3; + window.WebSocket = MockWS; + + window.fetch = async (url, init) => { + const u = new URL(url, location.href); + const body = init && init.body ? JSON.parse(init.body) : {}; + window.__http.push({ path: u.pathname, body, origin: u.origin, credentials: init && init.credentials }); + const route = (window.__httpScript || (() => ({ json: { resumable: false } }))); + const { status = 200, json = {} } = route(u.pathname, body) || {}; + return { ok: status >= 200 && status < 300, status, json: async () => json }; + }; +})(); +`; + +const sleep = `(ms) => new Promise((r) => setTimeout(r, ms))`; + +test('consent + phone + fingerprint produce the exact create_conversation_session payload', async ({ page }) => { + const pageErrors: string[] = []; + page.on('pageerror', (e) => pageErrors.push(`${e.name}: ${e.message}`)); + + await page.addInitScript(MOCK_WS); + await page.goto(`http://127.0.0.1:${process.env.STATIC_PORT ?? 4830}/e2e/fixtures/demo.html`).catch(async () => { + await page.goto('about:blank'); + }); + await page.addScriptTag({ content: GLOBAL_BUNDLE }); + + const result = await page.evaluate( + async ({ endpoint, agentId }) => { + const sleepFn = (ms: number) => new Promise((r) => setTimeout(r, ms)); + try { + localStorage.clear(); + } catch { + /* ignore */ + } + (window as unknown as { __script: unknown }).__script = (f: Record, reply: (o: unknown) => void) => { + if (f.action === 'create_conversation_session') { + reply({ type: 'immediate_response', requestId: f.requestId, status: 202, data: { sessionId: 'sess-A', conversationId: 'c', agentId: f.agentId } }); + } + }; + // Require name+email so the pre-chat form gates; phone + consent ride along. + // @ts-expect-error injected global + const el = window.SmoothAgentChat.mount({ endpoint, agentId, greeting: '', requireName: true, requireEmail: true }); + const root = (el as { shadowRoot: ShadowRoot }).shadowRoot; + (root.querySelector('.launcher') as HTMLElement | null)?.click(); + await sleepFn(30); + + // Fill the pre-chat form: name, email, phone, tick both consent boxes. + (root.querySelector('input[name=name]') as HTMLInputElement).value = 'Ada'; + (root.querySelector('input[name=email]') as HTMLInputElement).value = 'ada@example.com'; + (root.querySelector('input[name=phone]') as HTMLInputElement).value = '+15551234567'; + (root.querySelector('input[name=emailOptIn]') as HTMLInputElement).checked = true; + (root.querySelector('input[name=smsOptIn]') as HTMLInputElement).checked = true; + // Dispatch a real submit event the handler listens for. + (root.querySelector('.pc-form') as HTMLFormElement).dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); + + for (let i = 0; i < 80; i++) { + if ((window as unknown as { __sent: Record[] }).__sent.some((s) => s.action === 'create_conversation_session')) break; + await sleepFn(25); + } + const sent = (window as unknown as { __sent: Record[] }).__sent; + return sent.find((s) => s.action === 'create_conversation_session') ?? null; + }, + { endpoint: ENDPOINT, agentId: AGENT_ID }, + ); + + expect(pageErrors, pageErrors.join('\n')).toEqual([]); + expect(result, 'create_conversation_session was sent').toBeTruthy(); + const create = result as Record; + expect(create.userName).toBe('Ada'); + expect(create.userEmail).toBe('ada@example.com'); + expect(typeof create.browserFingerprint).toBe('string'); + const meta = create.metadata as Record; + expect(meta.userPhone).toBe('+15551234567'); + const consent = meta.consent as Record; + expect(consent.emailOptIn).toBe(true); + expect(consent.smsOptIn).toBe(true); + expect(consent.consentSource).toBe('chat-widget-prechat'); + expect(typeof consent.consentAt).toBe('string'); +}); + +test('persist → reload → resume replays history and reuses the sessionId', async ({ page }) => { + const pageErrors: string[] = []; + page.on('pageerror', (e) => pageErrors.push(`${e.name}: ${e.message}`)); + + await page.addInitScript(MOCK_WS); + // Serve a real origin so localStorage persists across the reload. + await page.goto(`http://127.0.0.1:${process.env.STATIC_PORT ?? 4830}/e2e/fixtures/demo.html`).catch(async () => { + await page.goto('about:blank'); + }); + await page.addScriptTag({ content: GLOBAL_BUNDLE }); + + // --- First visit: create a session + exchange one turn. --- + await page.evaluate( + async ({ endpoint, agentId }) => { + const s = (ms: number) => new Promise((r) => setTimeout(r, ms)); + (window as unknown as { __script: unknown }).__script = (f: Record, reply: (o: unknown) => void) => { + if (f.action === 'create_conversation_session') reply({ type: 'immediate_response', requestId: f.requestId, status: 202, data: { sessionId: 'sess-RESUME', conversationId: 'c', agentId: f.agentId } }); + else if (f.action === 'send_message') { + reply({ type: 'immediate_response', requestId: f.requestId, status: 202, data: {} }); + reply({ type: 'eventual_response', requestId: f.requestId, status: 200, data: { requestId: f.requestId, status: 200, data: { messageId: 'm1', response: { responseParts: ['First answer'] } } } }); + } + }; + // @ts-expect-error injected global + const el = window.SmoothAgentChat.mount({ endpoint, agentId, greeting: '' }); + const root = (el as { shadowRoot: ShadowRoot }).shadowRoot; + (root.querySelector('.launcher') as HTMLElement | null)?.click(); + for (let i = 0; i < 60; i++) { + if (/online|ready/i.test((root.querySelector('.status-text') as HTMLElement | null)?.textContent ?? '')) break; + await s(25); + } + const input = root.querySelector('textarea') as HTMLTextAreaElement; + input.value = 'hi'; + input.dispatchEvent(new Event('input', { bubbles: true })); + (root.querySelector('.send') as HTMLElement | null)?.click(); + await s(120); + el.remove(); + }, + { endpoint: ENDPOINT, agentId: AGENT_ID }, + ); + + // --- Second visit (simulated reload): a NEW widget reads persisted state. --- + const result = await page.evaluate( + async ({ endpoint, agentId }) => { + const s = (ms: number) => new Promise((r) => setTimeout(r, ms)); + (window as unknown as { __sent: Record[] }).__sent = []; + (window as unknown as { __script: unknown }).__script = (f: Record, reply: (o: unknown) => void) => { + if (f.action === 'get_session') reply({ type: 'immediate_response', requestId: f.requestId, status: 200, data: { sessionId: f.sessionId, status: 'active', agentId } }); + else if (f.action === 'get_conversation_messages') + reply({ type: 'immediate_response', requestId: f.requestId, status: 200, data: { messages: [ + { id: 'm1', direction: 'inbound', content: { text: 'hi' }, createdAt: '2026-01-01T00:00:00Z' }, + { id: 'm2', direction: 'outbound', content: { text: 'First answer' }, createdAt: '2026-01-01T00:00:01Z' }, + ].reverse(), hasMore: false } }); + else if (f.action === 'send_message') { + reply({ type: 'immediate_response', requestId: f.requestId, status: 202, data: {} }); + reply({ type: 'eventual_response', requestId: f.requestId, status: 200, data: { requestId: f.requestId, status: 200, data: { messageId: 'm3', response: { responseParts: ['Second answer'] } } } }); + } + }; + // @ts-expect-error injected global + const el = window.SmoothAgentChat.mount({ endpoint, agentId, greeting: '' }); + const root = (el as { shadowRoot: ShadowRoot }).shadowRoot; + // Returning visitor: pre-chat form skipped. Open + resume. + (root.querySelector('.launcher') as HTMLElement | null)?.click(); + for (let i = 0; i < 80; i++) { + const txt = Array.from(root.querySelectorAll('.bubble')).map((b) => b.textContent ?? '').join(' '); + if (/First answer/.test(txt)) break; + await s(25); + } + const sent = (window as unknown as { __sent: Record[] }).__sent; + // Send a follow-up; assert it reuses sess-RESUME. + const input = root.querySelector('textarea') as HTMLTextAreaElement | null; + if (input) { + input.value = 'more'; + input.dispatchEvent(new Event('input', { bubbles: true })); + (root.querySelector('.send') as HTMLElement | null)?.click(); + await s(120); + } + const after = (window as unknown as { __sent: Record[] }).__sent; + return { + hadCreate: sent.some((x) => x.action === 'create_conversation_session'), + resumed: sent.some((x) => x.action === 'get_session'), + history: Array.from(root.querySelectorAll('.bubble')).map((b) => b.textContent ?? ''), + sendSessionId: (after.find((x) => x.action === 'send_message') as Record | undefined)?.sessionId ?? null, + }; + }, + { endpoint: ENDPOINT, agentId: AGENT_ID }, + ); + + expect(pageErrors, pageErrors.join('\n')).toEqual([]); + expect(result.hadCreate, 'resume must NOT create a new session').toBe(false); + expect(result.resumed, 'resume must call get_session').toBe(true); + expect(result.history.join(' ')).toContain('First answer'); + expect(result.sendSessionId, 'follow-up reuses the resumed sessionId').toBe('sess-RESUME'); +}); + +test('ended session clears the pointer and starts fresh', async ({ page }) => { + const pageErrors: string[] = []; + page.on('pageerror', (e) => pageErrors.push(`${e.name}: ${e.message}`)); + + await page.addInitScript(MOCK_WS); + await page.goto(`http://127.0.0.1:${process.env.STATIC_PORT ?? 4830}/e2e/fixtures/demo.html`).catch(async () => { + await page.goto('about:blank'); + }); + await page.addScriptTag({ content: GLOBAL_BUNDLE }); + + const result = await page.evaluate( + async ({ endpoint, agentId }) => { + const s = (ms: number) => new Promise((r) => setTimeout(r, ms)); + // Pre-seed a persisted pointer for a session the server will report ended. + localStorage.setItem(`smoo-chat-widget:${agentId}`, JSON.stringify({ state: { version: 1, sessionId: 'sess-ENDED', identity: { name: 'Ada' }, consent: { emailOptIn: false, smsOptIn: false }, verifiedEmail: null, browserFingerprint: 'fp-keep' }, version: 1 })); + (window as unknown as { __script: unknown }).__script = (f: Record, reply: (o: unknown) => void) => { + if (f.action === 'get_session') reply({ type: 'immediate_response', requestId: f.requestId, status: 200, data: { sessionId: f.sessionId, status: 'ended', agentId } }); + else if (f.action === 'create_conversation_session') reply({ type: 'immediate_response', requestId: f.requestId, status: 202, data: { sessionId: 'sess-FRESH', conversationId: 'c', agentId: f.agentId } }); + }; + // @ts-expect-error injected global + const el = window.SmoothAgentChat.mount({ endpoint, agentId, greeting: '' }); + const root = (el as { shadowRoot: ShadowRoot }).shadowRoot; + (root.querySelector('.launcher') as HTMLElement | null)?.click(); + for (let i = 0; i < 80; i++) { + if ((window as unknown as { __sent: Record[] }).__sent.some((x) => x.action === 'create_conversation_session')) break; + await s(25); + } + const persisted = JSON.parse(localStorage.getItem(`smoo-chat-widget:${agentId}`) ?? '{}'); + const create = (window as unknown as { __sent: Record[] }).__sent.find((x) => x.action === 'create_conversation_session') as Record | undefined; + return { + createdFresh: !!create, + fingerprintSent: create?.browserFingerprint, + persistedSession: persisted.state?.sessionId, + persistedName: persisted.state?.identity?.name, + }; + }, + { endpoint: ENDPOINT, agentId: AGENT_ID }, + ); + + expect(pageErrors, pageErrors.join('\n')).toEqual([]); + expect(result.createdFresh, 'ended session → fresh create_conversation_session').toBe(true); + // Pointer rolled forward to the new session; identity + fingerprint preserved. + expect(result.persistedSession).toBe('sess-FRESH'); + expect(result.persistedName).toBe('Ada'); + expect(result.fingerprintSent).toBe('fp-keep'); +}); + +test('cross-device restore: request → verify → resolve → replay', async ({ page }) => { + const pageErrors: string[] = []; + page.on('pageerror', (e) => pageErrors.push(`${e.name}: ${e.message}`)); + + await page.addInitScript(MOCK_WS); + await page.goto(`http://127.0.0.1:${process.env.STATIC_PORT ?? 4830}/e2e/fixtures/demo.html`).catch(async () => { + await page.goto('about:blank'); + }); + await page.addScriptTag({ content: GLOBAL_BUNDLE }); + + const result = await page.evaluate( + async ({ endpoint, agentId }) => { + const s = (ms: number) => new Promise((r) => setTimeout(r, ms)); + try { + localStorage.clear(); + } catch { + /* ignore */ + } + // WS verbs: create the live session + serve the replayed history. + (window as unknown as { __script: unknown }).__script = (f: Record, reply: (o: unknown) => void) => { + switch (f.action) { + case 'create_conversation_session': + reply({ type: 'immediate_response', requestId: f.requestId, status: 202, data: { sessionId: 'sess-A', conversationId: 'c', agentId: f.agentId } }); + break; + case 'get_session': + reply({ type: 'immediate_response', requestId: f.requestId, status: 200, data: { sessionId: f.sessionId, status: 'active', agentId } }); + break; + case 'get_conversation_messages': + reply({ type: 'immediate_response', requestId: f.requestId, status: 200, data: { messages: [{ id: 'h1', direction: 'inbound', content: { text: 'Replayed pricing chat' }, createdAt: '2026-01-01T00:00:00Z' }], hasMore: false } }); + break; + } + }; + // HTTP /internal/* routes: the cross-device identity flow. + (window as unknown as { __httpScript: unknown }).__httpScript = (path: string, body: Record) => { + switch (path) { + case '/internal/resume-by-fingerprint': + return { json: { resumable: false } }; + case '/internal/identity/request-otp': + return { json: { event: 'otp_sent', maskedDestination: 'a***@example.com' } }; + case '/internal/identity/verify-otp': + return { json: body.code === '123456' ? { event: 'otp_verified' } : { event: 'otp_invalid', attemptsRemaining: 2 } }; + case '/internal/identity/resolve': + return { json: { resolved: true, crmContactId: 'crm', conversations: [{ conversationId: 'conv-9', sessionId: 'sess-9', lastActivityAt: '2026-01-01T00:00:00Z', preview: 'Past chat about pricing' }] } }; + default: + return { json: {} }; + } + }; + // @ts-expect-error injected global + const el = window.SmoothAgentChat.mount({ endpoint, agentId, greeting: '' }); + const root = (el as { shadowRoot: ShadowRoot }).shadowRoot; + (root.querySelector('.launcher') as HTMLElement | null)?.click(); + await s(40); + + // Click the "Restore my chats" affordance in the footer. + (root.querySelector('.restore-link') as HTMLElement | null)?.click(); + await s(30); + // Email entry. + const emailInput = root.querySelector('.int-card .int-input') as HTMLInputElement; + emailInput.value = 'ada@example.com'; + (root.querySelector('.int-card .int-btn.primary') as HTMLElement).click(); + await s(40); + // Code entry. + const codeInput = root.querySelector('.int-card .int-input') as HTMLInputElement; + codeInput.value = '123456'; + (root.querySelector('.int-card .int-btn.primary') as HTMLElement).click(); + await s(60); + // The resolved conversation list appears — pick it. + const item = root.querySelector('.restore-item') as HTMLElement | null; + const hadList = !!item; + item?.click(); + await s(80); + + return { + hadList, + verifiedEmailPersisted: JSON.parse(localStorage.getItem(`smoo-chat-widget:${agentId}`) ?? '{}').state?.verifiedEmail, + replayed: Array.from(root.querySelectorAll('.bubble')).map((b) => b.textContent ?? '').join(' '), + }; + }, + { endpoint: ENDPOINT, agentId: AGENT_ID }, + ); + + expect(pageErrors, pageErrors.join('\n')).toEqual([]); + expect(result.hadList, 'resolve_identity produced a conversation list').toBe(true); + expect(result.verifiedEmailPersisted, 'verifiedEmail persisted after verify').toBe('ada@example.com'); + expect(result.replayed).toContain('Replayed pricing chat'); +}); + +test('fingerprint resume: no persisted pointer → POST /internal/resume-by-fingerprint → adopt + replay', async ({ page }) => { + const pageErrors: string[] = []; + page.on('pageerror', (e) => pageErrors.push(`${e.name}: ${e.message}`)); + + await page.addInitScript(MOCK_WS); + await page.goto(`http://127.0.0.1:${process.env.STATIC_PORT ?? 4830}/e2e/fixtures/demo.html`).catch(async () => { + await page.goto('about:blank'); + }); + await page.addScriptTag({ content: GLOBAL_BUNDLE }); + + const result = await page.evaluate( + async ({ endpoint, agentId }) => { + const s = (ms: number) => new Promise((r) => setTimeout(r, ms)); + try { + localStorage.clear(); + } catch { + /* ignore */ + } + // No persisted pointer. The wrapper resolves a recent session for this + // fingerprint and primes the registry; the widget adopts it. + (window as unknown as { __httpScript: unknown }).__httpScript = (path: string) => { + if (path === '/internal/resume-by-fingerprint') { + return { json: { resumable: true, sessionId: 'sess-FP', conversationId: 'c', agentId } }; + } + return { json: {} }; + }; + (window as unknown as { __script: unknown }).__script = (f: Record, reply: (o: unknown) => void) => { + if (f.action === 'get_session') reply({ type: 'immediate_response', requestId: f.requestId, status: 200, data: { sessionId: f.sessionId, status: 'active', agentId } }); + else if (f.action === 'get_conversation_messages') reply({ type: 'immediate_response', requestId: f.requestId, status: 200, data: { messages: [{ id: 'fp1', direction: 'outbound', content: { text: 'Welcome back' }, createdAt: '2026-01-01T00:00:00Z' }], hasMore: false } }); + else if (f.action === 'create_conversation_session') reply({ type: 'immediate_response', requestId: f.requestId, status: 202, data: { sessionId: 'sess-SHOULD-NOT-HAPPEN', conversationId: 'c', agentId: f.agentId } }); + }; + // @ts-expect-error injected global + const el = window.SmoothAgentChat.mount({ endpoint, agentId, greeting: '' }); + const root = (el as { shadowRoot: ShadowRoot }).shadowRoot; + (root.querySelector('.launcher') as HTMLElement | null)?.click(); + for (let i = 0; i < 80; i++) { + const txt = Array.from(root.querySelectorAll('.bubble')).map((b) => b.textContent ?? '').join(' '); + if (/Welcome back/.test(txt)) break; + await s(25); + } + const sent = (window as unknown as { __sent: Record[] }).__sent; + const http = (window as unknown as { __http: { path: string; body: Record }[] }).__http; + return { + hitFingerprintRoute: http.some((h) => h.path === '/internal/resume-by-fingerprint'), + fingerprintSent: http.find((h) => h.path === '/internal/resume-by-fingerprint')?.body.browserFingerprint, + createdSession: sent.some((x) => x.action === 'create_conversation_session'), + persistedSession: JSON.parse(localStorage.getItem(`smoo-chat-widget:${agentId}`) ?? '{}').state?.sessionId, + replayed: Array.from(root.querySelectorAll('.bubble')).map((b) => b.textContent ?? '').join(' '), + }; + }, + { endpoint: ENDPOINT, agentId: AGENT_ID }, + ); + + expect(pageErrors, pageErrors.join('\n')).toEqual([]); + expect(result.hitFingerprintRoute, 'POST /internal/resume-by-fingerprint was called').toBe(true); + expect(typeof result.fingerprintSent).toBe('string'); + // Adopted the resumed session — did NOT create a fresh one. + expect(result.createdSession, 'must NOT create a session when fingerprint resumes').toBe(false); + expect(result.persistedSession).toBe('sess-FP'); + expect(result.replayed).toContain('Welcome back'); +}); + +test("hardening: every /internal/* POST omits credentials, restore-link awaits connect, and request-otp carries a sessionId", async ({ page }) => { + const pageErrors: string[] = []; + page.on('pageerror', (e) => pageErrors.push(`${e.name}: ${e.message}`)); + + await page.addInitScript(MOCK_WS); + await page.goto(`http://127.0.0.1:${process.env.STATIC_PORT ?? 4830}/e2e/fixtures/demo.html`).catch(async () => { + await page.goto('about:blank'); + }); + await page.addScriptTag({ content: GLOBAL_BUNDLE }); + + const result = await page.evaluate( + async ({ endpoint, agentId }) => { + const s = (ms: number) => new Promise((r) => setTimeout(r, ms)); + try { + localStorage.clear(); + } catch { + /* ignore */ + } + (window as unknown as { __script: unknown }).__script = (f: Record, reply: (o: unknown) => void) => { + switch (f.action) { + case 'create_conversation_session': + reply({ type: 'immediate_response', requestId: f.requestId, status: 202, data: { sessionId: 'sess-A', conversationId: 'c', agentId: f.agentId } }); + break; + case 'get_session': + reply({ type: 'immediate_response', requestId: f.requestId, status: 200, data: { sessionId: f.sessionId, status: 'active', agentId } }); + break; + case 'get_conversation_messages': + reply({ type: 'immediate_response', requestId: f.requestId, status: 200, data: { messages: [], hasMore: false } }); + break; + } + }; + (window as unknown as { __httpScript: unknown }).__httpScript = (path: string) => { + switch (path) { + case '/internal/resume-by-fingerprint': + return { json: { resumable: false } }; + case '/internal/identity/request-otp': + return { json: { event: 'otp_sent', maskedDestination: 'a***@example.com' } }; + default: + return { json: {} }; + } + }; + // @ts-expect-error injected global + const el = window.SmoothAgentChat.mount({ endpoint, agentId, greeting: '' }); + const root = (el as { shadowRoot: ShadowRoot }).shadowRoot; + (root.querySelector('.launcher') as HTMLElement | null)?.click(); + await s(30); + + // Click "Restore my chats" then IMMEDIATELY submit the email — racing the + // (now awaited) connect(). The hardened flow must still have a sessionId on + // the request-otp POST. + (root.querySelector('.restore-link') as HTMLElement | null)?.click(); + await s(10); + const emailInput = root.querySelector('.int-card .int-input') as HTMLInputElement; + emailInput.value = 'ada@example.com'; + (root.querySelector('.int-card .int-btn.primary') as HTMLElement).click(); + + // Wait for the request-otp call to land. + for (let i = 0; i < 80; i++) { + if ((window as unknown as { __http: { path: string }[] }).__http.some((h) => h.path === '/internal/identity/request-otp')) break; + await s(25); + } + const http = (window as unknown as { __http: { path: string; body: Record; credentials?: string }[] }).__http; + const otp = http.find((h) => h.path === '/internal/identity/request-otp'); + return { + allOmit: http.length > 0 && http.every((h) => h.credentials === 'omit'), + requestOtpHasSession: typeof otp?.body.sessionId === 'string' && (otp.body.sessionId as string).length > 0, + sentSession: otp?.body.sessionId, + }; + }, + { endpoint: ENDPOINT, agentId: AGENT_ID }, + ); + + expect(pageErrors, pageErrors.join('\n')).toEqual([]); + expect(result.allOmit, "every /internal/* POST must use credentials: 'omit'").toBe(true); + expect(result.requestOtpHasSession, 'request-otp must carry a sessionId even when racing the restore-link connect()').toBe(true); + expect(result.sentSession).toBe('sess-A'); +}); diff --git a/package.json b/package.json index 9a74ba3..3171e20 100644 --- a/package.json +++ b/package.json @@ -70,7 +70,8 @@ "ci:publish": "pnpm build && changeset publish" }, "dependencies": { - "@smooai/smooth-operator": "^1.8.0" + "@smooai/smooth-operator": "^1.8.0", + "zustand": "^5.0.14" }, "devDependencies": { "@changesets/cli": "^2.28.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 466d016..bd93d8f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,6 +12,9 @@ importers: '@smooai/smooth-operator': specifier: ^1.8.0 version: 1.8.0 + zustand: + specifier: ^5.0.14 + version: 5.0.14 devDependencies: '@changesets/cli': specifier: ^2.28.1 @@ -1485,6 +1488,24 @@ packages: xmlchars@2.2.0: resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + zustand@5.0.14: + resolution: {integrity: sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': '>=18.0.0' + immer: '>=9.0.6' + react: '>=18.0.0' + use-sync-external-store: '>=1.2.0' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + use-sync-external-store: + optional: true + snapshots: '@asamuzakjp/css-color@3.2.0': @@ -2811,3 +2832,5 @@ snapshots: xml-name-validator@5.0.0: {} xmlchars@2.2.0: {} + + zustand@5.0.14: {} diff --git a/src/config.ts b/src/config.ts index 891cf50..543aebe 100644 --- a/src/config.ts +++ b/src/config.ts @@ -71,6 +71,13 @@ export interface ChatWidgetConfig { userEmail?: string; /** Optional phone number for the user participant (passed via session metadata). */ userPhone?: string; + /** + * Optional pre-auth HMAC context. When the host page has a shared secret with + * the agent, it can sign `{ userId, signature, timestamp }` so the chat-ws + * wrapper's `/internal/*` identity routes (and the WS create path) verify the + * caller without an OTP round-trip (ADR-046/ADR-048). Passed through verbatim. + */ + authContext?: { userId: string; signature: string; timestamp: number }; /** Placeholder text for the message input. */ placeholder?: string; /** Greeting rendered when the conversation opens (before any messages). */ @@ -90,6 +97,24 @@ export interface ChatWidgetConfig { requireEmail?: boolean; /** Require the visitor's phone before chatting. */ requirePhone?: boolean; + /** + * Show the phone field on the pre-chat form (optional unless {@link requirePhone}). + * Defaults to `true` for this widget — phone rides the session metadata as + * `userPhone` so the agent can follow up by SMS. Set `false` to hide it. + */ + collectPhone?: boolean; + /** + * Show the email + SMS marketing-consent checkboxes on the pre-chat form. + * Explicit opt-in, default UNCHECKED; a `consentAt` timestamp is stamped when + * a box is ticked. Defaults to `true`. The consent record is threaded into the + * session metadata (ADR-048). + */ + collectConsent?: boolean; + /** + * Offer the cross-device "Restore my chats" affordance — an explicit link that + * runs the identity-OTP → resolve → replay flow. Defaults to `true`. + */ + allowChatRestore?: boolean; /** * Let visitors chat without providing any identity. When `true`, the * `require*` flags are ignored and the pre-chat form is skipped. @@ -102,11 +127,12 @@ export interface ChatWidgetConfig { /** The fully-resolved theme (canonical keys only — aliases are folded in). */ export type ResolvedTheme = Required>; -export type ResolvedConfig = Required> & { +export type ResolvedConfig = Required> & { theme: ResolvedTheme; userName?: string; userEmail?: string; userPhone?: string; + authContext?: { userId: string; signature: string; timestamp: number }; }; /** Resolve a partial config against the built-in defaults. */ @@ -127,6 +153,7 @@ export function resolveConfig(config: ChatWidgetConfig): ResolvedConfig { userName: config.userName, userEmail: config.userEmail, userPhone: config.userPhone, + authContext: config.authContext, placeholder: config.placeholder ?? 'Type a message…', greeting: config.greeting ?? 'Hi! How can I help you today?', connectionErrorMessage: config.connectionErrorMessage ?? "We couldn't reach the chat. Please try again in a moment.", @@ -135,6 +162,9 @@ export function resolveConfig(config: ChatWidgetConfig): ResolvedConfig { requireName: config.requireName ?? false, requireEmail: config.requireEmail ?? false, requirePhone: config.requirePhone ?? false, + collectPhone: config.collectPhone ?? true, + collectConsent: config.collectConsent ?? true, + allowChatRestore: config.allowChatRestore ?? true, allowAnonymous: config.allowAnonymous ?? false, theme: { text: theme.text ?? '#f8fafc', diff --git a/src/conversation.test.ts b/src/conversation.test.ts new file mode 100644 index 0000000..20dc5ae --- /dev/null +++ b/src/conversation.test.ts @@ -0,0 +1,629 @@ +/** + * ConversationController integration tests (jsdom + a deterministic mock + * WebSocket + a mocked `fetch`). + * + * The engine WS verbs (create/send/get_session/get_conversation_messages) are + * exercised through a scriptable mock WebSocket. The 0.7.0 cross-device identity + * flow + fingerprint resume are HTTP POST routes on the chat-ws wrapper (the + * engine `/ws` rejects unknown verbs — ADR-048), so those are exercised through a + * mocked `fetch` that routes by `/internal/*` path. + * + * Coverage: + * - createConversationSession carries browserFingerprint + identity + consent + * + phone-on-metadata, + * - same-session resume hydrates history and reuses the sessionId, + * - an ended session clears the pointer and starts fresh, + * - fingerprint resume (POST /internal/resume-by-fingerprint) adopts a session, + * - cross-device request → verify → resolve → replay over the HTTP routes. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { ConversationController, type ConversationEvents, httpBaseFromWsEndpoint, type IdentityRestore } from './conversation.js'; +import { createWidgetStore } from './persistence.js'; + +const ENDPOINT = 'wss://example.test/ws'; +const AGENT = 'agent-xyz'; + +interface Listeners { + open: Array<() => void>; + message: Array<(ev: { data: string }) => void>; + close: Array<(ev: { code?: number; reason?: string }) => void>; + error: Array<(ev: unknown) => void>; +} + +/** A scriptable mock socket. `onFrame` decides the replies for each outbound frame. */ +class MockSocket { + static readonly CONNECTING = 0; + static readonly OPEN = 1; + static readonly CLOSING = 2; + static readonly CLOSED = 3; + readonly CONNECTING = 0; + readonly OPEN = 1; + readonly CLOSING = 2; + readonly CLOSED = 3; + static onFrame: (frame: Record, reply: (obj: unknown) => void) => void = () => {}; + static instances: MockSocket[] = []; + static sentFrames: Record[] = []; + + readyState = 0; + private listeners: Listeners = { open: [], message: [], close: [], error: [] }; + + constructor(public url: string) { + MockSocket.instances.push(this); + queueMicrotask(() => { + this.readyState = 1; + for (const fn of this.listeners.open.slice()) fn(); + }); + } + addEventListener(type: keyof Listeners, fn: (ev: never) => void): void { + (this.listeners[type] as Array).push(fn); + } + removeEventListener(): void { + /* not needed for these tests */ + } + private reply(obj: unknown): void { + for (const fn of this.listeners.message.slice()) fn({ data: JSON.stringify(obj) }); + } + send(raw: string): void { + const frame = JSON.parse(raw) as Record; + MockSocket.sentFrames.push(frame); + MockSocket.onFrame(frame, (obj) => this.reply(obj)); + } + close(): void { + this.readyState = 3; + for (const fn of this.listeners.close.slice()) fn({ code: 1000, reason: '' }); + } +} + +function installMockWs(): void { + MockSocket.instances = []; + MockSocket.sentFrames = []; + (globalThis as unknown as { WebSocket: unknown }).WebSocket = MockSocket; +} + +/** Default operator behaviour: create → immediate_response, send → token+eventual. */ +function defaultOnFrame(frame: Record, reply: (obj: unknown) => void): void { + const requestId = frame.requestId; + if (frame.action === 'create_conversation_session') { + reply({ type: 'immediate_response', requestId, status: 202, data: { sessionId: 'sess-new', conversationId: 'conv-1', agentId: frame.agentId } }); + } else if (frame.action === 'send_message') { + reply({ type: 'immediate_response', requestId, status: 202, data: {} }); + reply({ type: 'stream_token', requestId, token: 'Hi' }); + reply({ type: 'eventual_response', requestId, status: 200, data: { requestId, status: 200, data: { messageId: 'm1', response: { responseParts: ['Hi there'] } } } }); + } +} + +// --- mocked fetch for the chat-ws `/internal/*` routes ----------------------- + +interface FetchCall { + path: string; + body: Record; + origin: string; + credentials?: RequestCredentials; +} +const fetchCalls: FetchCall[] = []; +/** Per-test responder: path → JSON body (+ optional status). Default = resume:false. */ +let fetchRouter: (path: string, body: Record) => { status?: number; json: Record } = () => ({ json: { resumable: false } }); + +function installMockFetch(): void { + fetchCalls.length = 0; + fetchRouter = () => ({ json: { resumable: false } }); + (globalThis as unknown as { fetch: unknown }).fetch = async (url: string, init?: { body?: string; credentials?: RequestCredentials }) => { + const u = new URL(url); + const body = init?.body ? (JSON.parse(init.body) as Record) : {}; + fetchCalls.push({ path: u.pathname, body, origin: u.origin, credentials: init?.credentials }); + const { status = 200, json } = fetchRouter(u.pathname, body); + return { + ok: status >= 200 && status < 300, + status, + json: async () => json, + }; + }; +} + +function makeController(events: Partial = {}, config: Record = {}) { + const store = createWidgetStore(AGENT); + const onMessages = vi.fn(); + const onStatus = vi.fn(); + const onInterrupt = vi.fn(); + const onIdentityRestore = vi.fn(); + const controller = new ConversationController( + { endpoint: ENDPOINT, agentId: AGENT, ...config }, + { onMessages, onStatus, onInterrupt, onIdentityRestore, ...events }, + store, + ); + return { controller, store, onMessages, onStatus, onIdentityRestore }; +} + +const tick = () => new Promise((r) => setTimeout(r, 0)); + +describe('ConversationController — session creation (ADR-048 §a)', () => { + beforeEach(() => { + localStorage.clear(); + installMockWs(); + installMockFetch(); + MockSocket.onFrame = defaultOnFrame; + }); + afterEach(() => localStorage.clear()); + + it('sends browserFingerprint + identity + consent + phone-on-metadata', async () => { + const { controller, store } = makeController(); + controller.setUserInfo({ + name: 'Ada', + email: 'ada@example.com', + phone: '+15551234567', + consent: { emailOptIn: true, smsOptIn: false }, + }); + await controller.connect(); + + const create = MockSocket.sentFrames.find((f) => f.action === 'create_conversation_session') as Record; + expect(create).toBeTruthy(); + expect(create.userName).toBe('Ada'); + expect(create.userEmail).toBe('ada@example.com'); + expect(typeof create.browserFingerprint).toBe('string'); + expect((create.browserFingerprint as string).length).toBeGreaterThan(10); + + const meta = create.metadata as Record; + // Phone rides metadata.userPhone (no first-class engine field). + expect(meta.userPhone).toBe('+15551234567'); + const consent = meta.consent as Record; + expect(consent.emailOptIn).toBe(true); + expect(consent.smsOptIn).toBe(false); + expect(consent.consentSource).toBe('chat-widget-prechat'); + expect(typeof consent.consentAt).toBe('string'); + + // The fingerprint + session pointer are persisted. + expect(store.getState().browserFingerprint).toBe(create.browserFingerprint); + expect(store.getState().sessionId).toBe('sess-new'); + }); + + it('reuses the SAME fingerprint across two sessions', async () => { + const { controller, store } = makeController(); + await controller.connect(); + const fp1 = store.getState().browserFingerprint; + controller.disconnect(); + // Clear the session pointer so the next connect creates a fresh session. + store.getState().clearSession(); + await controller.connect(); + const creates = MockSocket.sentFrames.filter((f) => f.action === 'create_conversation_session'); + expect(creates).toHaveLength(2); + expect(creates[0]?.browserFingerprint).toBe(creates[1]?.browserFingerprint); + expect(store.getState().browserFingerprint).toBe(fp1); + }); + + it('omits consent metadata when no opt-in was given', async () => { + const { controller } = makeController(); + controller.setUserInfo({ name: 'Bob', consent: { emailOptIn: false, smsOptIn: false } }); + await controller.connect(); + const create = MockSocket.sentFrames.find((f) => f.action === 'create_conversation_session') as Record; + // No consentAt stamped → no consent block in metadata. + expect(create.metadata).toBeUndefined(); + }); +}); + +describe('ConversationController — same-session resume (ADR-048 §b)', () => { + beforeEach(() => { + localStorage.clear(); + installMockWs(); + installMockFetch(); + }); + afterEach(() => localStorage.clear()); + + it('resumes a live persisted session, hydrates history, and reuses the sessionId', async () => { + // Seed a persisted pointer + identity (returning visitor). + const seed = createWidgetStore(AGENT); + seed.getState().setSessionId('sess-resume'); + seed.getState().mergeIdentity({ name: 'Ada' }); + + MockSocket.onFrame = (frame, reply) => { + const requestId = frame.requestId; + if (frame.action === 'get_session') { + expect(frame.sessionId).toBe('sess-resume'); + reply({ type: 'immediate_response', requestId, status: 200, data: { sessionId: 'sess-resume', status: 'active', agentId: AGENT } }); + } else if (frame.action === 'get_conversation_messages') { + // Server returns newest-first. + reply({ + type: 'immediate_response', + requestId, + status: 200, + data: { + messages: [ + { id: 'm2', direction: 'outbound', content: { text: 'Reply two' }, createdAt: '2026-01-02T00:00:00Z' }, + { id: 'm1', direction: 'inbound', content: { text: 'Hello one' }, createdAt: '2026-01-01T00:00:00Z' }, + ], + hasMore: false, + }, + }); + } else { + defaultOnFrame(frame, reply); + } + }; + + const { controller, onMessages } = makeController(); + await controller.connect(); + + // No new session was created — we resumed. + expect(MockSocket.sentFrames.find((f) => f.action === 'create_conversation_session')).toBeUndefined(); + expect(MockSocket.sentFrames.find((f) => f.action === 'get_session')).toBeTruthy(); + + // History hydrated chronologically (oldest first). + const lastSnapshot = onMessages.mock.calls.at(-1)?.[0] as Array<{ role: string; text: string }>; + expect(lastSnapshot.map((m) => m.text)).toEqual(['Hello one', 'Reply two']); + expect(lastSnapshot.map((m) => m.role)).toEqual(['user', 'assistant']); + + // A follow-up message reuses sess-resume (no createConversationSession). + await controller.send('next'); + const sendFrame = MockSocket.sentFrames.find((f) => f.action === 'send_message') as Record; + expect(sendFrame.sessionId).toBe('sess-resume'); + expect(MockSocket.sentFrames.find((f) => f.action === 'create_conversation_session')).toBeUndefined(); + }); + + it('clears the pointer (keeps identity) and starts fresh when the session has ended', async () => { + const seed = createWidgetStore(AGENT); + seed.getState().setSessionId('sess-dead'); + seed.getState().mergeIdentity({ name: 'Ada' }); + + 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); + } + }; + + const { controller, store } = makeController(); + await controller.connect(); + + // Fell through to a fresh create_conversation_session. + const create = MockSocket.sentFrames.find((f) => f.action === 'create_conversation_session') as Record; + expect(create).toBeTruthy(); + // New pointer persisted; identity preserved across the clear. + expect(store.getState().sessionId).toBe('sess-new'); + expect(store.getState().identity.name).toBe('Ada'); + }); + + it('starts fresh when get_session 404s (SESSION_NOT_FOUND)', async () => { + const seed = createWidgetStore(AGENT); + seed.getState().setSessionId('sess-gone'); + + MockSocket.onFrame = (frame, reply) => { + const requestId = frame.requestId; + if (frame.action === 'get_session') { + reply({ type: 'error', requestId, data: { requestId, error: { code: 'SESSION_NOT_FOUND', message: 'gone' } } }); + } else { + defaultOnFrame(frame, reply); + } + }; + + const { controller, store } = makeController(); + await controller.connect(); + expect(MockSocket.sentFrames.find((f) => f.action === 'create_conversation_session')).toBeTruthy(); + expect(store.getState().sessionId).toBe('sess-new'); + }); +}); + +describe('httpBaseFromWsEndpoint', () => { + it('derives the HTTP base from a wss /ws endpoint', () => { + expect(httpBaseFromWsEndpoint('wss://ai.smoo.ai/ws')).toBe('https://ai.smoo.ai'); + expect(httpBaseFromWsEndpoint('ws://localhost:8787/ws')).toBe('http://localhost:8787'); + expect(httpBaseFromWsEndpoint('wss://ai.smoo.ai:9000/ws')).toBe('https://ai.smoo.ai:9000'); + }); + + it('FAILS LOUD (returns null) on a non-absolute endpoint instead of a relative base', () => { + // A relative base would make fetch(`${base}/internal/...`) POST identity data + // to the HOST page origin instead of the operator host. Null forces a refusal. + expect(httpBaseFromWsEndpoint('/ws')).toBeNull(); + expect(httpBaseFromWsEndpoint('not a url')).toBeNull(); + expect(httpBaseFromWsEndpoint('')).toBeNull(); + }); +}); + +describe('ConversationController — fail-loud on a non-absolute endpoint', () => { + beforeEach(() => { + localStorage.clear(); + installMockWs(); + installMockFetch(); + MockSocket.onFrame = defaultOnFrame; + }); + afterEach(() => localStorage.clear()); + + it('enters error status and refuses /internal/* calls (no fetch) when the endpoint is not absolute', async () => { + const store = createWidgetStore(AGENT); + const onStatus = vi.fn(); + const onIdentityRestore = vi.fn(); + const controller = new ConversationController( + { endpoint: '/ws', agentId: AGENT }, + { onMessages: vi.fn(), onStatus, onInterrupt: vi.fn(), onIdentityRestore }, + store, + ); + await tick(); + // The constructor flagged the controller in error via a microtask. + expect(onStatus).toHaveBeenCalledWith('error', expect.stringContaining('/ws')); + + // request-otp must NOT fire a fetch — it refuses with an identity-restore error. + await controller.requestIdentityOtp('ada@example.com'); + expect(fetchCalls.length).toBe(0); + const last = onIdentityRestore.mock.calls.at(-1)?.[0] as IdentityRestore | undefined; + expect(last?.phase).toBe('error'); + }); +}); + +describe('ConversationController — fingerprint resume (ADR-048 §b, HTTP)', () => { + beforeEach(() => { + localStorage.clear(); + installMockWs(); + installMockFetch(); + MockSocket.onFrame = defaultOnFrame; + }); + afterEach(() => localStorage.clear()); + + it('POSTs /internal/resume-by-fingerprint FIRST when there is no persisted pointer, then adopts the session', async () => { + // Wrapper resolves a recent session for this fingerprint and primes the registry. + fetchRouter = (path) => { + if (path === '/internal/resume-by-fingerprint') { + return { json: { resumable: true, sessionId: 'sess-FP', conversationId: 'c', agentId: AGENT } }; + } + return { json: {} }; + }; + MockSocket.onFrame = (frame, reply) => { + const requestId = frame.requestId; + if (frame.action === 'get_session') { + reply({ type: 'immediate_response', requestId, status: 200, data: { sessionId: frame.sessionId, status: 'active', agentId: AGENT } }); + } else if (frame.action === 'get_conversation_messages') { + reply({ type: 'immediate_response', requestId, status: 200, data: { messages: [{ id: 'fp1', direction: 'outbound', content: { text: 'Welcome back' }, createdAt: '2026-01-01T00:00:00Z' }], hasMore: false } }); + } else { + defaultOnFrame(frame, reply); + } + }; + + const { controller, store, onMessages } = makeController(); + await controller.connect(); + + // The fingerprint resume route was hit with the persisted fingerprint + agentId. + const fpCall = fetchCalls.find((c) => c.path === '/internal/resume-by-fingerprint'); + expect(fpCall).toBeTruthy(); + expect(fpCall?.body.browserFingerprint).toBe(store.getState().browserFingerprint); + expect(fpCall?.body.agentId).toBe(AGENT); + + // Adopted the session via get_session/get_messages — NOT createConversationSession. + expect(MockSocket.sentFrames.find((f) => f.action === 'create_conversation_session')).toBeUndefined(); + expect(store.getState().sessionId).toBe('sess-FP'); + const snap = onMessages.mock.calls.at(-1)?.[0] as Array<{ text: string }>; + expect(snap.some((m) => m.text === 'Welcome back')).toBe(true); + }); + + it('falls through to createConversationSession when the fingerprint is not resumable', async () => { + fetchRouter = () => ({ json: { resumable: false } }); + const { controller, store } = makeController(); + await controller.connect(); + expect(fetchCalls.some((c) => c.path === '/internal/resume-by-fingerprint')).toBe(true); + expect(MockSocket.sentFrames.find((f) => f.action === 'create_conversation_session')).toBeTruthy(); + expect(store.getState().sessionId).toBe('sess-new'); + }); +}); + +describe('ConversationController — cross-device restore (ADR-048 §c, HTTP)', () => { + beforeEach(() => { + localStorage.clear(); + installMockWs(); + installMockFetch(); + }); + afterEach(() => localStorage.clear()); + + it('runs request → verify → resolve → replay over the HTTP routes and persists verifiedEmail', async () => { + const restoreStates: IdentityRestore[] = []; + // A live session must exist before the cross-device affordance — create one. + MockSocket.onFrame = (frame, reply) => { + const requestId = frame.requestId; + switch (frame.action) { + case 'create_conversation_session': + reply({ type: 'immediate_response', requestId, status: 202, data: { sessionId: 'sess-new', conversationId: 'c', agentId: AGENT } }); + break; + case 'get_session': + reply({ type: 'immediate_response', requestId, status: 200, data: { sessionId: frame.sessionId, status: 'active', agentId: AGENT } }); + break; + case 'get_conversation_messages': + reply({ type: 'immediate_response', requestId, status: 200, data: { messages: [{ id: 'h1', direction: 'inbound', content: { text: 'Restored history' }, createdAt: '2026-01-01T00:00:00Z' }], hasMore: false } }); + break; + default: + break; + } + }; + fetchRouter = (path, body) => { + switch (path) { + case '/internal/resume-by-fingerprint': + return { json: { resumable: false } }; + case '/internal/identity/request-otp': + expect(body.email).toBe('ada@example.com'); + expect(body.agentId).toBe(AGENT); + return { json: { event: 'otp_sent', maskedDestination: 'a***@example.com' } }; + case '/internal/identity/verify-otp': + expect(body.sessionId).toBe('sess-new'); + expect(body.code).toBe('123456'); + return { json: { event: 'otp_verified' } }; + case '/internal/identity/resolve': + expect(body.sessionId).toBe('sess-new'); + return { json: { resolved: true, crmContactId: 'crm-1', conversations: [{ conversationId: 'conv-9', sessionId: 'sess-9', lastActivityAt: '2026-01-01T00:00:00Z', preview: 'Past chat' }] } }; + default: + return { json: {} }; + } + }; + + const { controller, store, onMessages } = makeController({ onIdentityRestore: (s) => restoreStates.push(s) }); + await controller.connect(); + + await controller.requestIdentityOtp('ada@example.com', 'email'); + expect(restoreStates.at(-1)?.phase).toBe('awaiting_code'); + + await controller.verifyIdentityOtp('123456'); + await tick(); + // verifiedEmail persisted on success. + expect(store.getState().verifiedEmail).toBe('ada@example.com'); + + const resolved = restoreStates.find((s) => s.phase === 'resolved'); + expect(resolved && resolved.phase === 'resolved' ? resolved.conversations[0]?.sessionId : null).toBe('sess-9'); + + // Picking the conversation replays its history + repoints the session. + await controller.restoreConversation('sess-9'); + await tick(); + expect(store.getState().sessionId).toBe('sess-9'); + const snap = onMessages.mock.calls.at(-1)?.[0] as Array<{ text: string }>; + expect(snap.some((m) => m.text === 'Restored history')).toBe(true); + }); + + it('surfaces otp_invalid with attemptsRemaining and stays on the code step', async () => { + const restoreStates: IdentityRestore[] = []; + MockSocket.onFrame = defaultOnFrame; + fetchRouter = (path) => { + switch (path) { + case '/internal/resume-by-fingerprint': + return { json: { resumable: false } }; + case '/internal/identity/request-otp': + return { json: { event: 'otp_sent', maskedDestination: 'a***@x.com' } }; + case '/internal/identity/verify-otp': + return { json: { event: 'otp_invalid', attemptsRemaining: 2 } }; + default: + return { json: {} }; + } + }; + const { controller } = makeController({ onIdentityRestore: (s) => restoreStates.push(s) }); + await controller.connect(); + await controller.requestIdentityOtp('ada@example.com'); + await controller.verifyIdentityOtp('000000'); + await tick(); + const last = restoreStates.at(-1); + expect(last?.phase).toBe('awaiting_code'); + expect(last?.phase === 'awaiting_code' ? last.attemptsRemaining : null).toBe(2); + }); + + it('passes authContext through to the identity routes when configured', async () => { + MockSocket.onFrame = defaultOnFrame; + fetchRouter = () => ({ json: { resumable: false } }); + const ac = { userId: 'u1', signature: 'sig', timestamp: 123 }; + const { controller } = makeController({}, { authContext: ac }); + await controller.connect(); + await controller.requestIdentityOtp('ada@example.com'); + const otpCall = fetchCalls.find((c) => c.path === '/internal/identity/request-otp'); + expect(otpCall?.body.authContext).toEqual(ac); + }); +}); + +describe('ConversationController — adversarial-review hardening (SMOODEV-2129e)', () => { + beforeEach(() => { + localStorage.clear(); + installMockWs(); + installMockFetch(); + MockSocket.onFrame = defaultOnFrame; + }); + afterEach(() => localStorage.clear()); + + it("posts every /internal/* route with credentials: 'omit' (CORS allowlist auth, not cookies)", async () => { + fetchRouter = (path) => { + if (path === '/internal/identity/request-otp') return { json: { event: 'otp_sent', maskedDestination: 'a***@x.com' } }; + return { json: { resumable: false } }; + }; + const { controller } = makeController(); + // connect() with no pointer fires /internal/resume-by-fingerprint. + await controller.connect(); + // and the identity route fires too. + await controller.requestIdentityOtp('ada@example.com'); + + expect(fetchCalls.length).toBeGreaterThan(0); + for (const call of fetchCalls) { + expect(call.credentials, `${call.path} must omit credentials`).toBe('omit'); + } + }); + + it('does NOT thread verifiedEmail into a brand-new create_conversation_session', async () => { + // Seed a verifiedEmail bound to some OTHER session — it must not leak onto a + // freshly created session (which on a shared browser could be a new visitor). + const seed = createWidgetStore(AGENT); + seed.getState().setSessionId('sess-other'); + seed.getState().setVerifiedEmail('verified@example.com', 'sess-other'); + seed.getState().clearSession(); // clearing leaves no pointer → fresh create path + + // After clearSession the proof is gone; even if it weren't, a fresh create + // must never carry it. Re-seed it post-clear to prove the create path itself + // refuses to stamp it. + seed.getState().setVerifiedEmail('verified@example.com', 'sess-other'); + + const { controller } = makeController(); + await controller.connect(); + const create = MockSocket.sentFrames.find((f) => f.action === 'create_conversation_session') as Record; + expect(create).toBeTruthy(); + const meta = (create.metadata ?? {}) as Record; + expect(meta.verifiedEmail).toBeUndefined(); + }); + + it('cross-device verify binds verifiedEmail to the live session id', async () => { + MockSocket.onFrame = defaultOnFrame; + fetchRouter = (path) => { + switch (path) { + case '/internal/resume-by-fingerprint': + return { json: { resumable: false } }; + case '/internal/identity/request-otp': + return { json: { event: 'otp_sent', maskedDestination: 'a***@x.com' } }; + case '/internal/identity/verify-otp': + return { json: { event: 'otp_verified' } }; + case '/internal/identity/resolve': + return { json: { resolved: true, conversations: [] } }; + default: + return { json: {} }; + } + }; + const { controller, store } = makeController(); + await controller.connect(); // creates sess-new + await controller.requestIdentityOtp('ada@example.com'); + await controller.verifyIdentityOtp('123456'); + await tick(); + expect(store.getState().verifiedEmail).toBe('ada@example.com'); + // Bound to the live session, not left unbound. + expect(store.getState().verifiedEmailSessionId).toBe('sess-new'); + }); + + it('requestIdentityOtp establishes a session first, so request-otp carries a sessionId (no "No active session" on verify)', async () => { + // Simulate the restore-link race: the visitor submits the email BEFORE any + // prior connect(). requestIdentityOtp must connect, then send sessionId. + fetchRouter = (path) => { + if (path === '/internal/identity/request-otp') return { json: { event: 'otp_sent', maskedDestination: 'a***@x.com' } }; + return { json: { resumable: false } }; + }; + const { controller } = makeController(); + // No connect() called first — straight to requestIdentityOtp. + await controller.requestIdentityOtp('ada@example.com'); + + const otpCall = fetchCalls.find((c) => c.path === '/internal/identity/request-otp'); + expect(otpCall, 'request-otp was sent').toBeTruthy(); + // A session was established and threaded through. + expect(typeof otpCall?.body.sessionId).toBe('string'); + expect(otpCall?.body.sessionId).toBe('sess-new'); + }); + + it('resume probe runs at most once: re-entering connect() after an error does not re-POST resume-by-fingerprint', async () => { + let createCalls = 0; + MockSocket.onFrame = (frame, reply) => { + const requestId = frame.requestId; + if (frame.action === 'create_conversation_session') { + createCalls += 1; + if (createCalls === 1) { + // First create fails → controller goes to error. + reply({ type: 'error', requestId, data: { requestId, error: { code: 'BOOM', message: 'transient' } } }); + } else { + reply({ type: 'immediate_response', requestId, status: 202, data: { sessionId: 'sess-2', conversationId: 'c', agentId: frame.agentId } }); + } + } + }; + fetchRouter = () => ({ json: { resumable: false } }); + + const { controller } = makeController(); + await expect(controller.connect()).rejects.toBeTruthy(); + // One resume-by-fingerprint probe on the first connect. + const probesAfterFirst = fetchCalls.filter((c) => c.path === '/internal/resume-by-fingerprint').length; + expect(probesAfterFirst).toBe(1); + + // Re-enter connect() after the error — must NOT re-run the resume probe. + await controller.connect(); + const probesAfterSecond = fetchCalls.filter((c) => c.path === '/internal/resume-by-fingerprint').length; + expect(probesAfterSecond, 'resume probe must run at most once').toBe(1); + }); +}); diff --git a/src/conversation.ts b/src/conversation.ts index 553a0a1..a090886 100644 --- a/src/conversation.ts +++ b/src/conversation.ts @@ -8,15 +8,54 @@ * so the swap is purely at the client-library boundary. * * Flow: - * 1. `connect()` → opens the WebSocket transport and `create_conversation_session`. + * 1. `connect()` → opens the WebSocket transport and `create_conversation_session` + * (or RESUMES a persisted session via `get_session`/`get_messages`). * 2. `send(text)` → `send_message`, streaming `stream_token` deltas into the * in-progress assistant message, then the terminal * `eventual_response`. * + * 0.7.0 (SMOODEV-2129e) adds the identity / persistence / consent client layer: + * - `browserFingerprint` computed once + sent on every `create_conversation_session`. + * - identity + marketing consent threaded into the session `metadata`. + * - same-session RESUME on load (no engine change — `get_session` + `get_messages`). + * - returning-visitor RESUME via `POST /internal/resume-by-fingerprint`, and + * cross-device "restore my chats" via the `POST /internal/identity/{request-otp, + * verify-otp,resolve}` routes on the chat-ws wrapper. The engine (smooth-operator + * 1.8.0) owns the `/ws` dispatch and REJECTS unknown verbs, so these are HTTP + * `fetch()` calls (origin-allowlisted + optional authContext, per ADR-046/048) — + * NOT WS frames. + * * The controller is UI-agnostic: it emits typed events and the view renders them. */ import { type Citation, ProtocolError, type ServerEvent, SmoothAgentClient } from '@smooai/smooth-operator'; +import type { StoreApi } from 'zustand/vanilla'; import type { ChatWidgetConfig } from './config.js'; +import { getOrCreateFingerprint } from './fingerprint.js'; +import { type ConsentState, createWidgetStore, type WidgetStore } from './persistence.js'; + +/** + * Derive the HTTP base for the chat-ws wrapper's `/internal/*` REST routes from + * the WS endpoint: `wss://ai.smoo.ai/ws` → `https://ai.smoo.ai`. The engine + * (smooth-operator 1.8.0) owns the `/ws` dispatch and REJECTS unknown verbs, so + * the cross-device identity flow + fingerprint resume are HTTP POST routes on the + * wrapper (origin-allowlisted + authContext, per ADR-046/ADR-048) — NOT WS frames. + */ +export function httpBaseFromWsEndpoint(endpoint: string): string | null { + try { + const u = new URL(endpoint); + u.protocol = u.protocol === 'ws:' ? 'http:' : u.protocol === 'wss:' ? 'https:' : u.protocol; + // The REST routes live at the host root (`/internal/*`), not under `/ws`. + return `${u.protocol}//${u.host}`; + } catch { + // FAIL LOUD on a non-absolute endpoint. A relative fallback (e.g. + // `string.replace`) would yield a relative base, and `fetch(\`${base}/internal/...\`)` + // would then POST identity/OTP data to the HOST page origin (e.g. smoo.ai) + // instead of the operator host (ai.smoo.ai) — leaking it to the wrong origin. + // Returning null forces the controller into an error state and refuses the + // `/internal/*` calls rather than mis-targeting them. + return null; + } +} export type { Citation }; @@ -66,8 +105,35 @@ export interface UserInfo { name?: string; email?: string; phone?: string; + /** Marketing-consent opt-ins captured at the pre-chat form (ADR-048). */ + consent?: { emailOptIn: boolean; smsOptIn: boolean }; +} + +/** One conversation surfaced by `resolve_identity` for the cross-device picker. */ +export interface RestorableConversation { + conversationId: string; + sessionId: string; + lastActivityAt?: string; + preview?: string; } +/** + * State machine for the cross-device "restore my chats" flow. Driven by the three + * HTTP POST routes on the chat-ws wrapper — `/internal/identity/request-otp` → + * `/internal/identity/verify-otp` → `/internal/identity/resolve` (ADR-048 §c). + * The view renders a panel off this. + */ +export type IdentityRestore = + | { phase: 'idle' } + /** UI-local: the email-entry step before any request is sent. */ + | { phase: 'awaiting_email'; error?: string } + | { phase: 'requesting'; email: string; channel: 'email' | 'sms' } + | { phase: 'awaiting_code'; email: string; channel: 'email' | 'sms'; maskedDestination?: string; error?: string; attemptsRemaining?: number } + | { phase: 'verifying'; email: string; channel: 'email' | 'sms' } + | { phase: 'resolving'; email: string } + | { phase: 'resolved'; email: string; conversations: RestorableConversation[] } + | { phase: 'error'; message: string }; + export interface ConversationEvents { /** Fired whenever the message list changes (append, token delta, finalize). */ onMessages: (messages: ChatMessage[]) => void; @@ -75,6 +141,8 @@ export interface ConversationEvents { onStatus: (status: ConnectionStatus, detail?: string) => void; /** Fired when a turn pauses for OTP / tool-confirmation, and `null` when it clears. */ onInterrupt?: (interrupt: Interrupt | null) => void; + /** Fired on cross-device identity-restore state transitions. */ + onIdentityRestore?: (state: IdentityRestore) => void; } /** Pull the final assistant text out of an `eventual_response` data payload. */ @@ -115,33 +183,110 @@ function extractCitations(inner: unknown): Citation[] { return out; } +/** A `get_conversation_messages` row, narrowed defensively off the wire. */ +interface WireMessage { + id?: string; + direction?: 'inbound' | 'outbound'; + content?: { text?: string }; + createdAt?: string; +} + +/** Convert a server message row into a finalized {@link ChatMessage}. */ +function wireMessageToChat(m: WireMessage, idx: number): ChatMessage | null { + const text = typeof m.content?.text === 'string' ? m.content.text : ''; + if (!text) return null; + const role: Role = m.direction === 'outbound' ? 'assistant' : 'user'; + return { id: typeof m.id === 'string' ? m.id : `hist-${idx}`, role, text, streaming: false }; +} + export class ConversationController { private readonly config: ChatWidgetConfig; private readonly events: ConversationEvents; + private readonly store: StoreApi; private client: SmoothAgentClient | null = null; private sessionId: string | null = null; private readonly messages: ChatMessage[] = []; private status: ConnectionStatus = 'idle'; private seq = 0; - /** Visitor identity, seeded from config and updated by the pre-chat form. */ - private identity: UserInfo; /** requestId of the in-flight turn — used to resume OTP / tool confirmations. */ private activeRequestId: string | null = null; private interrupt: Interrupt | null = null; + private identityRestore: IdentityRestore = { phase: 'idle' }; + /** + * True once the resume probe (persisted-pointer get_session OR the + * `/internal/resume-by-fingerprint` POST) has run for this controller. Makes + * `connect()` idempotent: re-entering after a transient `error` status (e.g. a + * retried `send()`) creates a fresh session rather than re-running the resume + * probe — which would fire another `resumeByFingerprint` POST and could adopt a + * session we already decided not to resume. + */ + private resumeAttempted = false; + /** + * HTTP base for the chat-ws wrapper's `/internal/*` REST routes. `null` when + * the configured WS endpoint could not be parsed into an absolute origin — in + * that case the `/internal/*` routes are refused (rather than mis-targeted at + * the host page origin). See {@link httpBaseFromWsEndpoint}. + */ + private readonly httpBase: string | null; - constructor(config: ChatWidgetConfig, events: ConversationEvents) { + constructor(config: ChatWidgetConfig, events: ConversationEvents, store?: StoreApi) { this.config = config; this.events = events; - this.identity = { name: config.userName, email: config.userEmail, phone: config.userPhone }; + this.httpBase = httpBaseFromWsEndpoint(config.endpoint); + if (this.httpBase === null) { + // A non-absolute endpoint means the identity / resume `/internal/*` routes + // have no safe target. Flag the controller in error so the UI surfaces it + // and the routes refuse (see postInternal) rather than mis-targeting the + // host page origin. Deferred to a microtask so listeners attached after + // construction still observe the transition. + queueMicrotask(() => this.setStatus('error', `Invalid chat endpoint: ${config.endpoint}`)); + } + this.store = store ?? createWidgetStore(config.agentId); + // Seed identity from config into the persisted store. `mergeIdentity` is + // applied on EVERY construct, so a config-provided field always wins over + // the persisted value (config is authoritative when present). Fields the + // config does NOT provide keep their persisted value — those survive across + // reloads; explicitly-configured ones are re-applied each load. + const seed: { name?: string; email?: string; phone?: string } = {}; + if (config.userName) seed.name = config.userName; + if (config.userEmail) seed.email = config.userEmail; + if (config.userPhone) seed.phone = config.userPhone; + if (Object.keys(seed).length > 0) this.store.getState().mergeIdentity(seed); } get connectionStatus(): ConnectionStatus { return this.status; } - /** Merge in visitor identity (from the pre-chat form). Applied on next connect. */ + /** The persisted store, exposed so the view can read identity for the pre-chat gate. */ + getStore(): StoreApi { + return this.store; + } + + /** True when a persisted session pointer exists (drives the resume path). */ + hasPersistedSession(): boolean { + return !!this.store.getState().sessionId; + } + + /** True when persisted identity exists (lets the view skip the pre-chat form). */ + hasPersistedIdentity(): boolean { + const id = this.store.getState().identity; + return !!(id.name || id.email || id.phone); + } + + /** Merge in visitor identity + consent (from the pre-chat form). Applied on next connect. */ setUserInfo(info: UserInfo): void { - this.identity = { ...this.identity, ...info }; + const { name, email, phone, consent } = info; + this.store.getState().mergeIdentity({ name, email, phone }); + if (consent) { + const consentAt = consent.emailOptIn || consent.smsOptIn ? new Date().toISOString() : undefined; + this.store.getState().setConsent({ + emailOptIn: consent.emailOptIn, + smsOptIn: consent.smsOptIn, + consentSource: 'chat-widget-prechat', + consentAt, + }); + } } private setInterrupt(interrupt: Interrupt | null): void { @@ -149,6 +294,15 @@ export class ConversationController { this.events.onInterrupt?.(interrupt); } + private setIdentityRestore(state: IdentityRestore): void { + this.identityRestore = state; + this.events.onIdentityRestore?.(state); + } + + get currentIdentityRestore(): IdentityRestore { + return this.identityRestore; + } + /** Submit an OTP code to resume the paused turn. No-op if not awaiting OTP. */ verifyOtp(code: string): void { if (!this.client || !this.sessionId || !this.activeRequestId || this.interrupt?.kind !== 'otp') return; @@ -177,21 +331,115 @@ export class ConversationController { this.events.onMessages(this.messages.map((m) => ({ ...m }))); } - /** Open the transport and create a conversation session. Idempotent. */ + /** Compute (once) + return the persisted browser fingerprint. */ + private fingerprint(): string { + const state = this.store.getState(); + return getOrCreateFingerprint( + () => state.browserFingerprint, + (fp) => this.store.getState().setBrowserFingerprint(fp), + ); + } + + /** + * Build the `metadata` payload threaded into `create_conversation_session`: + * phone (no first-class engine field) and consent. + * + * NOTE: `verifiedEmail` is deliberately NOT stamped here. It is a per-session + * OTP proof bound to the session it was verified against + * (`verifiedEmailSessionId`), and the server only treats an actual OTP `verify` + * as proof — metadata `verifiedEmail` is just a hint. Auto-stamping it onto + * every brand-new `create_conversation_session` would mislabel a fresh + * visitor's session with a prior (possibly different) visitor's email on a + * shared browser. The verified email is only used when RESUMING the exact + * session it was proven for — see {@link verifiedEmailForSession}. + */ + private sessionMetadata(): Record | undefined { + const state = this.store.getState(); + const meta: Record = {}; + if (state.identity.phone) meta.userPhone = state.identity.phone; + const consent = state.consent; + if (consent.emailOptIn || consent.smsOptIn || consent.consentAt) { + const c: ConsentState = { + emailOptIn: consent.emailOptIn, + smsOptIn: consent.smsOptIn, + consentSource: consent.consentSource ?? 'chat-widget-prechat', + }; + if (consent.consentAt) c.consentAt = consent.consentAt; + meta.consent = c; + } + return Object.keys(meta).length > 0 ? meta : undefined; + } + + /** + * The verified-email hint, but ONLY when the OTP proof is bound to the session + * being resumed (`verifiedEmailSessionId === sessionId`). Returns null + * otherwise so a stale/cross-visitor proof is never threaded onto a session it + * wasn't proven for. + */ + private verifiedEmailForSession(sessionId: string): string | null { + const state = this.store.getState(); + if (state.verifiedEmail && state.verifiedEmailSessionId === sessionId) { + return state.verifiedEmail; + } + return null; + } + + /** Lazily open the WS client (default transport). Idempotent within a connect. */ + private async ensureClient(): Promise { + if (this.client) return; + this.client = new SmoothAgentClient({ url: this.config.endpoint }); + await this.client.connect(); + } + + /** + * Open the connection and either RESUME or create a session. + * + * 1. Persisted pointer (ADR-048 §b): `get_session` → if not `ended`, reuse + + * hydrate from `get_messages` (newest-first, reversed). On ended/404 clear + * ONLY the pointer (identity/consent survive). + * 2. No persisted pointer: POST `/internal/resume-by-fingerprint` FIRST; if + * `resumable`, adopt the returned session (the wrapper has primed the + * operator registry), reuse the sessionId, and hydrate via get_session/ + * get_messages — rather than relying on createConversationSession to resume. + * 3. Otherwise create a fresh session. + */ async connect(): Promise { if (this.status === 'connecting' || this.status === 'ready') return; this.setStatus('connecting'); try { - this.client = new SmoothAgentClient({ url: this.config.endpoint }); - await this.client.connect(); - const session = await this.client.createConversationSession({ - agentId: this.config.agentId, - userName: this.identity.name, - userEmail: this.identity.email, - // Phone has no first-class field yet; carry it in session metadata. - ...(this.identity.phone ? { metadata: { userPhone: this.identity.phone } } : {}), - }); - this.sessionId = session.sessionId; + await this.ensureClient(); + // The resume probe (persisted-pointer get_session OR the fingerprint + // resume POST) runs AT MOST ONCE per controller lifecycle. Re-entering + // connect() after a transient error (e.g. a retried send()) must not + // re-run the probe — that would re-fire resumeByFingerprint and could + // adopt a session we already chose not to resume. After the first + // attempt, fall straight through to creating a fresh session. + if (!this.resumeAttempted) { + this.resumeAttempted = true; + const persistedSessionId = this.store.getState().sessionId; + if (persistedSessionId) { + const resumed = await this.tryResume(persistedSessionId); + if (resumed) { + this.setStatus('ready'); + return; + } + // Resume failed (ended/404/gone) — clear the pointer, keep identity. + this.store.getState().clearSession(); + } else { + // Returning anonymous visitor with no stored pointer: ask the + // wrapper to resolve a recent session for this fingerprint. + const fpSessionId = await this.resumeByFingerprint(); + if (fpSessionId) { + const resumed = await this.tryResume(fpSessionId); + if (resumed) { + this.store.getState().setSessionId(fpSessionId); + this.setStatus('ready'); + return; + } + } + } + } + await this.createSession(); this.setStatus('ready'); } catch (err) { this.setStatus('error', err instanceof Error ? err.message : String(err)); @@ -199,6 +447,128 @@ export class ConversationController { } } + // ─────────────────────── chat-ws `/internal/*` HTTP ───────────────────────── + + /** + * Build the auth fields every `/internal/*` route shares: `agentId` (required + * for the agent-policy lookup), `agentName` (used as the OTP email sender), and + * the optional pre-auth `authContext` the host page may have configured. The + * `Origin` header is sent automatically by the browser and checked server-side. + */ + private authBody(): Record { + const body: Record = { agentId: this.config.agentId }; + if (this.config.agentName) body.agentName = this.config.agentName; + if (this.config.authContext) body.authContext = this.config.authContext; + return body; + } + + /** POST JSON to a `/internal/*` route; returns the parsed body (or throws). */ + private async postInternal(path: string, payload: Record): Promise> { + if (this.httpBase === null) { + // No absolute origin could be derived from the WS endpoint — refuse the + // call loudly rather than POST identity data to a relative (host-page) URL. + throw new Error(`Cannot reach ${path}: the chat endpoint is not an absolute URL.`); + } + const res = await fetch(`${this.httpBase}${path}`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + // Auth is the `Origin` allowlist + the `authContext` body field — NOT + // cookies. `credentials: 'include'` would force the server to reply with + // `Access-Control-Allow-Credentials: true` AND a reflected origin (a + // wildcard `*` is illegal with credentials), so a plain origin-allowlisted + // CORS config would fail the preflight and break EVERY `/internal/*` call. + // Omit credentials so the cross-origin POST works against an allowlist + // that doesn't (and shouldn't need to) opt into credentialed CORS. + credentials: 'omit', + body: JSON.stringify({ ...this.authBody(), ...payload }), + }); + let json: Record = {}; + try { + json = (await res.json()) as Record; + } catch { + json = {}; + } + if (!res.ok) { + const err = json.error as { code?: string; message?: string } | undefined; + throw new Error(err?.message ?? `${path} failed (${res.status})`); + } + return json; + } + + /** + * POST `/internal/resume-by-fingerprint`. Returns the resumable sessionId when + * the wrapper found (and primed) a recent session for this fingerprint, else + * null. Network/route failures are swallowed → null (fall through to create). + */ + private async resumeByFingerprint(): Promise { + try { + const json = await this.postInternal('/internal/resume-by-fingerprint', { browserFingerprint: this.fingerprint() }); + if (json.resumable === true && typeof json.sessionId === 'string') { + return json.sessionId; + } + } catch { + // Resume is best-effort; any failure just means a fresh session. + } + return null; + } + + /** `create_conversation_session` with fingerprint + identity + consent metadata. */ + private async createSession(): Promise { + if (!this.client) throw new Error('Conversation is not connected'); + const state = this.store.getState(); + const metadata = this.sessionMetadata(); + const session = await this.client.createConversationSession({ + agentId: this.config.agentId, + userName: state.identity.name, + userEmail: state.identity.email, + browserFingerprint: this.fingerprint(), + ...(metadata ? { metadata } : {}), + }); + this.sessionId = session.sessionId; + this.store.getState().setSessionId(session.sessionId); + } + + /** + * Attempt to resume `sessionId`: returns true and hydrates the transcript when + * the session is live, false when it has ended / can't be fetched. + */ + private async tryResume(sessionId: string): Promise { + if (!this.client) return false; + let snap: { status?: 'active' | 'idle' | 'ended' }; + try { + snap = await this.client.getSession({ sessionId }); + } catch { + return false; // 404 / SESSION_NOT_FOUND / network — start fresh. + } + if (snap.status === 'ended') return false; + + this.sessionId = sessionId; + await this.hydrateHistory(sessionId); + return true; + } + + /** Page recent history (newest-first), reverse to chronological, and render. */ + private async hydrateHistory(sessionId: string): Promise { + if (!this.client) return; + try { + const page = await this.client.getMessages({ sessionId, limit: 50 }); + const rows = Array.isArray(page.messages) ? page.messages : []; + // The server returns newest-first; reverse to chronological for the UI. + const chronological = [...rows].reverse(); + const hydrated: ChatMessage[] = []; + chronological.forEach((m, i) => { + const chat = wireMessageToChat(m as WireMessage, i); + if (chat) hydrated.push(chat); + }); + this.messages.length = 0; + this.messages.push(...hydrated); + this.emitMessages(); + } catch { + // History fetch is best-effort: a resumable session with no fetchable + // history just shows an empty transcript rather than failing the resume. + } + } + /** * Submit a user message. Appends the user bubble immediately, then streams the * assistant reply token-by-token, finalizing on `eventual_response`. @@ -309,12 +679,155 @@ export class ConversationController { } } + // ─────────────────── Cross-device "restore my chats" (§c) ─────────────────── + // + // Three HTTP POST routes on the chat-ws wrapper (the engine `/ws` dispatch + // rejects unknown verbs): request-otp → verify-otp → resolve. ALL THREE are + // session-scoped — they require a live `sessionId` (a uuid). request-otp + // establishes the session itself (idempotent connect) before sending, so the + // whole flow shares one session and verify-otp can't hit "No active session" + // even if the email was submitted before the initial connect() resolved. + + /** + * Begin the cross-device restore: POST `/internal/identity/request-otp` for + * `email` over `channel`. The view collects the email via an explicit affordance. + */ + async requestIdentityOtp(email: string, channel: 'email' | 'sms' = 'email'): Promise { + const trimmed = email.trim(); + if (!trimmed) return; + this.setIdentityRestore({ phase: 'requesting', email: trimmed, channel }); + // request-otp must be SESSION-CONSISTENT with verify-otp (which hard-requires + // a sessionId). If the restore affordance fired request-otp before connect() + // resolved, there'd be no sessionId here and verify-otp would later error + // "No active session." Establish a session first (idempotent connect), then + // require it — so the whole request → verify flow shares one live session. + if (!this.sessionId) { + try { + await this.connect(); + } catch { + /* fall through: handled by the sessionId check below */ + } + } + if (!this.sessionId) { + this.setIdentityRestore({ phase: 'error', message: 'No active session to verify against.' }); + return; + } + try { + const json = await this.postInternal('/internal/identity/request-otp', { + sessionId: this.sessionId, + email: trimmed, + channel, + }); + const masked = typeof json.maskedDestination === 'string' ? json.maskedDestination : undefined; + this.setIdentityRestore({ phase: 'awaiting_code', email: trimmed, channel, maskedDestination: masked }); + } catch (err) { + this.setIdentityRestore({ phase: 'error', message: err instanceof Error ? err.message : 'Could not send a verification code.' }); + } + } + + /** Submit the code: POST `/internal/identity/verify-otp`, then resolve on success. */ + async verifyIdentityOtp(code: string): Promise { + const state = this.identityRestore; + const trimmed = code.trim(); + if (!trimmed || state.phase !== 'awaiting_code') return; + const { email, channel } = state; + if (!this.sessionId) { + this.setIdentityRestore({ phase: 'error', message: 'No active session to verify against.' }); + return; + } + this.setIdentityRestore({ phase: 'verifying', email, channel }); + try { + const json = await this.postInternal('/internal/identity/verify-otp', { sessionId: this.sessionId, email, code: trimmed }); + if (json.event === 'otp_verified') { + // Bind the OTP proof to the session it was verified against, so it + // can't leak onto a different visitor's session on a shared browser. + this.store.getState().setVerifiedEmail(email, this.sessionId); + await this.resolveIdentity(email); + } else if (json.event === 'otp_invalid') { + const remaining = typeof json.attemptsRemaining === 'number' ? json.attemptsRemaining : undefined; + this.setIdentityRestore({ phase: 'awaiting_code', email, channel, error: 'That code was incorrect.', attemptsRemaining: remaining }); + } else { + this.setIdentityRestore({ phase: 'error', message: 'Verification failed.' }); + } + } catch (err) { + this.setIdentityRestore({ phase: 'error', message: err instanceof Error ? err.message : 'Verification failed.' }); + } + } + + /** Resolve the verified identity → restorable conversations via POST `/internal/identity/resolve`. */ + private async resolveIdentity(email: string): Promise { + if (!this.sessionId) return; + this.setIdentityRestore({ phase: 'resolving', email }); + try { + const json = await this.postInternal('/internal/identity/resolve', { sessionId: this.sessionId, email }); + if (json.resolved !== true) { + this.setIdentityRestore({ phase: 'resolved', email, conversations: [] }); + return; + } + const raw = json.conversations; + const conversations: RestorableConversation[] = Array.isArray(raw) + ? raw + .map((c): RestorableConversation | null => { + if (!c || typeof c !== 'object') return null; + const o = c as Record; + const conversationId = typeof o.conversationId === 'string' ? o.conversationId : ''; + const sessionId = typeof o.sessionId === 'string' ? o.sessionId : ''; + if (!sessionId) return null; + return { + conversationId, + sessionId, + lastActivityAt: typeof o.lastActivityAt === 'string' ? o.lastActivityAt : undefined, + preview: typeof o.preview === 'string' ? o.preview : undefined, + }; + }) + .filter((c): c is RestorableConversation => c !== null) + : []; + this.setIdentityRestore({ phase: 'resolved', email, conversations }); + } catch (err) { + this.setIdentityRestore({ phase: 'error', message: err instanceof Error ? err.message : 'Could not load your chats.' }); + } + } + + /** + * Replay a chosen restorable conversation: point the live session at its + * sessionId, hydrate its transcript (get_session + get_messages), and persist + * the new pointer so the next `sendMessage` continues it. + */ + async restoreConversation(sessionId: string): Promise { + if (!this.client) await this.ensureClient(); + // Capture the OTP proof bound to the CURRENT live session BEFORE tryResume + // repoints this.sessionId to the restored one. The visitor proved ownership + // of this email in this very flow, so the proof legitimately follows the + // conversation they chose to restore. + const proven = this.sessionId ? this.verifiedEmailForSession(this.sessionId) : null; + const resumed = await this.tryResume(sessionId); + if (resumed) { + this.store.getState().setSessionId(sessionId); + // Rebind the proof to the restored session (keeps verifiedEmail + // session-scoped, just now to the session it's actually used on). Only a + // proof from the just-verified session follows — never an unrelated stale one. + if (proven) this.store.getState().setVerifiedEmail(proven, sessionId); + this.setIdentityRestore({ phase: 'idle' }); + this.setStatus('ready'); + } else { + this.setIdentityRestore({ phase: 'error', message: 'That conversation is no longer available.' }); + } + } + + /** Dismiss the cross-device restore panel. */ + cancelIdentityRestore(): void { + this.setIdentityRestore({ phase: 'idle' }); + } + /** Tear down the underlying client. */ disconnect(): void { this.client?.disconnect('widget closed'); this.client = null; this.sessionId = null; this.activeRequestId = null; + // A full teardown ends the controller lifecycle: a subsequent connect() is a + // genuine re-open and may resume again, so re-arm the resume probe. + this.resumeAttempted = false; this.setInterrupt(null); this.setStatus('closed'); } diff --git a/src/element.ts b/src/element.ts index fb2dc59..cc7f62a 100644 --- a/src/element.ts +++ b/src/element.ts @@ -16,7 +16,7 @@ */ import type { ChatWidgetConfig, ChatWidgetMode, ChatWidgetTheme } from './config.js'; import { needsUserInfo, resolveConfig } from './config.js'; -import { type ChatMessage, type Citation, type ConnectionStatus, ConversationController, type Interrupt } from './conversation.js'; +import { type ChatMessage, type Citation, type ConnectionStatus, ConversationController, type IdentityRestore, type Interrupt } from './conversation.js'; import { SMOOTH_LOGO_SVG } from './logo.js'; import { cleanCitationSnippet, escapeHtml, renderMarkdown, safeHttpUrl } from './markdown.js'; import { buildStyles } from './styles.js'; @@ -73,6 +73,12 @@ export class SmoothAgentChatElement extends HTMLElement { /** Current mid-turn interrupt (OTP / tool-confirmation), or null. */ private interrupt: Interrupt | null = null; private interruptEl: HTMLElement | null = null; + /** Cross-device "restore my chats" flow state (ADR-048 §c). */ + private identityRestore: IdentityRestore = { phase: 'idle' }; + /** Whether the cross-device restore affordance is offered (config). */ + private allowChatRestore = true; + /** True while the pre-chat identity gate is showing (blocks premature connect). */ + private gating = false; // Cached DOM refs (populated in render()). private panelEl: HTMLElement | null = null; @@ -136,7 +142,10 @@ export class SmoothAgentChatElement extends HTMLElement { openChat(): void { this.open = true; this.syncOpenState(); - void this.controller?.connect().catch(() => {}); + // Don't connect while the pre-chat identity gate is unsatisfied — connecting + // here would create a session BEFORE the visitor submits their name/email/ + // consent, sending an empty identity. The form's submit handler connects. + if (!this.gating) void this.controller?.connect().catch(() => {}); } /** Collapse the chat panel back to the launcher. */ @@ -163,6 +172,7 @@ export class SmoothAgentChatElement extends HTMLElement { userName: this.overrides.userName, userEmail: this.overrides.userEmail, userPhone: this.overrides.userPhone, + authContext: this.overrides.authContext, placeholder: this.overrides.placeholder ?? this.getAttribute('placeholder') ?? undefined, greeting: this.overrides.greeting ?? this.getAttribute('greeting') ?? undefined, connectionErrorMessage: this.overrides.connectionErrorMessage, @@ -171,6 +181,9 @@ export class SmoothAgentChatElement extends HTMLElement { requireName: this.overrides.requireName, requireEmail: this.overrides.requireEmail, requirePhone: this.overrides.requirePhone, + collectPhone: this.overrides.collectPhone, + collectConsent: this.overrides.collectConsent, + allowChatRestore: this.overrides.allowChatRestore, allowAnonymous: this.overrides.allowAnonymous, theme, }; @@ -186,6 +199,8 @@ export class SmoothAgentChatElement extends HTMLElement { } const resolved = resolveConfig(config); + this.allowChatRestore = resolved.allowChatRestore; + // (Re)create the controller only when there isn't one yet. Attribute churn // (e.g. theme tweaks) re-renders the view without dropping the session. if (!this.controller) { @@ -202,8 +217,17 @@ export class SmoothAgentChatElement extends HTMLElement { this.interrupt = interrupt; this.renderInterrupt(); }, + onIdentityRestore: (state) => { + this.identityRestore = state; + this.renderInterrupt(); + }, }); if (resolved.startOpen) this.open = true; + // Returning visitor: a persisted session or identity lets us skip the + // pre-chat gate and resume straight into the conversation (ADR-048 §b). + if (this.controller.hasPersistedSession() || this.controller.hasPersistedIdentity()) { + this.userInfoSatisfied = true; + } } const fullpage = resolved.mode === 'fullpage'; @@ -242,8 +266,20 @@ export class SmoothAgentChatElement extends HTMLElement { // Gate the conversation behind a pre-chat identity form when required. const gating = needsUserInfo(resolved) && !this.userInfoSatisfied; - const field = (name: string, type: string, label: string, autocomplete: string) => - ``; + this.gating = gating; + // Phone is collected by default (optional unless requirePhone). Consent + // checkboxes default to shown, explicit + unchecked (ADR-048 §a/§3). + const showPhone = resolved.requirePhone || resolved.collectPhone; + const field = (name: string, type: string, label: string, autocomplete: string, required: boolean) => + ``; + const consentBox = (name: string, label: string) => + ``; + const consentHtml = resolved.collectConsent + ? `
+ ${consentBox('emailOptIn', 'Email me product news and offers.')} + ${consentBox('smsOptIn', 'Text me updates by SMS. Message/data rates may apply.')} +
` + : ''; const prechatHtml = `
@@ -251,12 +287,14 @@ export class SmoothAgentChatElement extends HTMLElement {
A couple details so ${escapeHtml(resolved.agentName)} can help.
- ${resolved.requireName ? field('name', 'text', 'Name', 'name') : ''} - ${resolved.requireEmail ? field('email', 'email', 'Email', 'email') : ''} - ${resolved.requirePhone ? field('phone', 'tel', 'Phone', 'tel') : ''} + ${resolved.requireName ? field('name', 'text', 'Name', 'name', true) : ''} + ${resolved.requireEmail ? field('email', 'email', 'Email', 'email', true) : ''} + ${showPhone ? field('phone', 'tel', 'Phone', 'tel', resolved.requirePhone) : ''} + ${consentHtml}
`; + const restoreLink = this.allowChatRestore ? ` · ` : ''; const chatHtml = `
@@ -265,7 +303,7 @@ export class SmoothAgentChatElement extends HTMLElement { - + `; const container = document.createElement('div'); @@ -313,6 +351,21 @@ export class SmoothAgentChatElement extends HTMLElement { this.handlePrechatSubmit(pcForm as HTMLFormElement); }); + // Cross-device "Restore my chats": open the panel + start the email entry. + // AWAIT connect() before showing the email step so a `sessionId` exists by + // the time the visitor submits — otherwise request-otp could go out with no + // session and verify-otp would then hard-error "No active session." The + // request-otp/verify-otp paths in the controller also require a session, so + // gating here keeps the affordance race-free. + container.querySelector('.restore-link')?.addEventListener('click', () => { + void (async () => { + this.identityRestore = { phase: 'awaiting_email' }; + this.renderInterrupt(); + // Establish a live session before the email entry can fire request-otp. + await this.controller?.connect().catch(() => {}); + })(); + }); + // Full-page mode connects eagerly (there's no launcher click to trigger it) — // but only once any identity gate is cleared. if (fullpage && !gating) void this.controller?.connect().catch(() => {}); @@ -335,6 +388,13 @@ export class SmoothAgentChatElement extends HTMLElement { el.replaceChildren(); const it = this.interrupt; if (!it) { + // No mid-turn interrupt — but the cross-device restore flow may be + // active, which reuses this same overlay slot. + if (this.identityRestore.phase !== 'idle') { + el.classList.remove('hidden'); + el.appendChild(this.buildRestoreCard()); + return; + } el.classList.add('hidden'); return; } @@ -420,12 +480,189 @@ export class SmoothAgentChatElement extends HTMLElement { el.appendChild(card); } - /** Collect identity from the pre-chat form, then drop into the chat view. */ + /** + * Build the cross-device "Restore my chats" card (ADR-048 §c). Reuses the + * same overlay slot + visual language as the OTP interrupt. All server-supplied + * strings (masked destination, conversation previews) are set via `textContent` + * — never innerHTML — so they can't inject markup; only the static lock icon + * uses innerHTML. + */ + private buildRestoreCard(): HTMLElement { + const state = this.identityRestore; + const card = document.createElement('div'); + card.className = 'int-card'; + + const head = document.createElement('div'); + head.className = 'int-head'; + const ico = document.createElement('span'); + ico.className = 'int-ico'; + ico.innerHTML = ICON.lock; // static, trusted + const title = document.createElement('span'); + title.className = 'int-title'; + title.textContent = 'Restore your chats'; + head.append(ico, title); + card.appendChild(head); + + const close = document.createElement('button'); + close.className = 'int-close'; + close.type = 'button'; + close.setAttribute('aria-label', 'Cancel'); + close.textContent = '×'; + close.addEventListener('click', () => { + this.controller?.cancelIdentityRestore(); + this.identityRestore = { phase: 'idle' }; + this.renderInterrupt(); + }); + card.appendChild(close); + + if (state.phase === 'awaiting_email') { + const desc = document.createElement('div'); + desc.className = 'int-desc'; + desc.textContent = "Enter your email and we'll send a code to find your previous chats."; + card.appendChild(desc); + + const row = document.createElement('div'); + row.className = 'int-row'; + const input = document.createElement('input'); + input.className = 'int-input'; + input.type = 'email'; + input.autocomplete = 'email'; + input.placeholder = 'you@example.com'; + const go = () => { + const email = input.value.trim(); + if (email) void this.controller?.requestIdentityOtp(email, 'email'); + }; + input.addEventListener('keydown', (ev) => { + if (ev.key === 'Enter') { + ev.preventDefault(); + go(); + } + }); + const send = document.createElement('button'); + send.className = 'int-btn primary'; + send.type = 'button'; + send.textContent = 'Send code'; + send.addEventListener('click', go); + row.append(input, send); + card.appendChild(row); + if (state.error) { + const err = document.createElement('div'); + err.className = 'int-error'; + err.textContent = state.error; + card.appendChild(err); + } + queueMicrotask(() => input.focus()); + } else if (state.phase === 'requesting' || state.phase === 'verifying' || state.phase === 'resolving') { + const msg = document.createElement('div'); + msg.className = 'int-sent'; + msg.textContent = state.phase === 'requesting' ? 'Sending a code…' : state.phase === 'verifying' ? 'Verifying…' : 'Finding your chats…'; + card.appendChild(msg); + } else if (state.phase === 'awaiting_code') { + if (state.maskedDestination) { + const sent = document.createElement('div'); + sent.className = 'int-sent'; + sent.textContent = `Code sent to ${state.maskedDestination}.`; + card.appendChild(sent); + } + const row = document.createElement('div'); + row.className = 'int-row'; + const input = document.createElement('input'); + input.className = 'int-input'; + input.type = 'text'; + input.inputMode = 'numeric'; + input.autocomplete = 'one-time-code'; + input.placeholder = 'Enter code'; + const submit = () => { + const code = input.value.trim(); + if (code) void this.controller?.verifyIdentityOtp(code); + }; + input.addEventListener('keydown', (ev) => { + if (ev.key === 'Enter') { + ev.preventDefault(); + submit(); + } + }); + const verify = document.createElement('button'); + verify.className = 'int-btn primary'; + verify.type = 'button'; + verify.textContent = 'Verify'; + verify.addEventListener('click', submit); + row.append(input, verify); + card.appendChild(row); + if (state.error) { + const err = document.createElement('div'); + err.className = 'int-error'; + err.textContent = state.attemptsRemaining != null ? `${state.error} (${state.attemptsRemaining} left)` : state.error; + card.appendChild(err); + } + queueMicrotask(() => input.focus()); + } else if (state.phase === 'resolved') { + if (state.conversations.length === 0) { + const none = document.createElement('div'); + none.className = 'int-desc'; + none.textContent = 'No previous chats found for that email.'; + card.appendChild(none); + } else { + const pick = document.createElement('div'); + pick.className = 'int-desc'; + pick.textContent = 'Pick a conversation to continue:'; + card.appendChild(pick); + const list = document.createElement('div'); + list.className = 'restore-list'; + for (const conv of state.conversations) { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'restore-item'; + const preview = document.createElement('span'); + preview.className = 'restore-preview'; + preview.textContent = conv.preview || 'Conversation'; + btn.appendChild(preview); + if (conv.lastActivityAt) { + const when = document.createElement('span'); + when.className = 'restore-when'; + when.textContent = this.formatWhen(conv.lastActivityAt); + btn.appendChild(when); + } + btn.addEventListener('click', () => { + void this.controller?.restoreConversation(conv.sessionId); + }); + list.appendChild(btn); + } + card.appendChild(list); + } + } else if (state.phase === 'error') { + const err = document.createElement('div'); + err.className = 'int-error'; + err.textContent = state.message; + card.appendChild(err); + } + + return card; + } + + /** Format an ISO timestamp as a short, locale-aware label (best-effort). */ + private formatWhen(iso: string): string { + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return ''; + try { + return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); + } catch { + return ''; + } + } + + /** Collect identity + consent from the pre-chat form, then drop into the chat view. */ private handlePrechatSubmit(form: HTMLFormElement): void { if (!form.reportValidity()) return; const data = new FormData(form); const val = (k: string) => ((data.get(k) as string | null)?.trim() || undefined); - this.controller?.setUserInfo({ name: val('name'), email: val('email'), phone: val('phone') }); + const checked = (k: string) => data.get(k) === 'on'; + this.controller?.setUserInfo({ + name: val('name'), + email: val('email'), + phone: val('phone'), + consent: { emailOptIn: checked('emailOptIn'), smsOptIn: checked('smsOptIn') }, + }); this.userInfoSatisfied = true; this.render(); void this.controller?.connect().catch(() => {}); diff --git a/src/fingerprint.test.ts b/src/fingerprint.test.ts new file mode 100644 index 0000000..8aefb55 --- /dev/null +++ b/src/fingerprint.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it, vi } from 'vitest'; +import { computeFingerprint, getOrCreateFingerprint } from './fingerprint.js'; + +describe('computeFingerprint', () => { + it('produces a non-empty UUID.signalHash token', () => { + const fp = computeFingerprint(); + expect(fp.length).toBeGreaterThan(10); + // Shape: .<8-hex> + const [uuid, hash] = fp.split('.'); + expect(uuid).toMatch(/^[0-9a-f-]{36}$/i); + expect(hash).toMatch(/^[0-9a-f]{8}$/i); + }); + + it('generates a fresh UUID each call (the persisted value is what stabilizes it)', () => { + expect(computeFingerprint()).not.toBe(computeFingerprint()); + }); + + it('keeps a stable signal-hash suffix across calls (same browser env)', () => { + const a = computeFingerprint().split('.')[1]; + const b = computeFingerprint().split('.')[1]; + expect(a).toBe(b); + }); +}); + +describe('getOrCreateFingerprint', () => { + it('computes + stores once, then returns the cached value', () => { + let stored: string | null = null; + const set = vi.fn((fp: string) => { + stored = fp; + }); + const get = () => stored; + + const first = getOrCreateFingerprint(get, set); + expect(set).toHaveBeenCalledTimes(1); + const second = getOrCreateFingerprint(get, set); + // No second write; same value returned. + expect(set).toHaveBeenCalledTimes(1); + expect(second).toBe(first); + }); + + it('returns the existing value without recomputing', () => { + const set = vi.fn(); + const fp = getOrCreateFingerprint(() => 'pre-existing.deadbeef', set); + expect(fp).toBe('pre-existing.deadbeef'); + expect(set).not.toHaveBeenCalled(); + }); +}); diff --git a/src/fingerprint.ts b/src/fingerprint.ts new file mode 100644 index 0000000..3bdb5bb --- /dev/null +++ b/src/fingerprint.ts @@ -0,0 +1,122 @@ +/** + * Browser fingerprint — a stable, privacy-light identifier used by the + * smooth-operator server to correlate an anonymous visitor's sessions across + * page loads (and, server-side, to match an anonymous fingerprint to a known + * CRM contact). It rides every `create_conversation_session` as + * `browserFingerprint` (see ADR-048). + * + * ## Why not ThumbmarkJS + * + * The ADR floats ThumbmarkJS as the reference implementation. For an *embed* + * that is injected onto arbitrary host pages, ThumbmarkJS is too heavy: its + * full build pulls in extensive device-detection tables and async + * component collection, adding tens of kilobytes (and async surface) to a + * bundle whose whole selling point is staying out of the host page's + * LCP/TBT budget. The fingerprint here is a few hundred bytes. + * + * ## What this is (and the tradeoff) + * + * The correlation that actually matters — "is this the same browser as last + * time?" — is carried by a **persisted random UUID** (the `browserFingerprint` + * field in the persisted store). That UUID is generated once and reused for + * the life of the localStorage entry, so same-browser resume is exact and + * deterministic. The signal hash below is a best-effort entropy *supplement* + * mixed into that UUID's derivation seed on first generation — it gives the + * server-side resolver a soft signal for the (rare) cross-storage case where + * the UUID was cleared but the device is unchanged. It is intentionally NOT a + * high-entropy device fingerprint: no canvas/WebGL/audio probes, no font + * enumeration, nothing that reads as invasive tracking. The tradeoff is weaker + * cross-storage matching in exchange for a tiny, transparent, no-network, + * XSS-safe implementation. The server's resolver (SMOODEV-2129d) is the source + * of truth for any fuzzy matching; the client just supplies a stable token. + */ + +/** Collect a small set of stable, non-invasive browser signals. */ +function collectSignals(): string { + const parts: string[] = []; + try { + const nav = typeof navigator !== 'undefined' ? navigator : undefined; + if (nav) { + parts.push(`ua:${nav.userAgent ?? ''}`); + parts.push(`lang:${nav.language ?? ''}`); + parts.push(`langs:${Array.isArray(nav.languages) ? nav.languages.join(',') : ''}`); + // `platform` is deprecated but still widely present and stable. + parts.push(`plat:${(nav as Navigator & { platform?: string }).platform ?? ''}`); + parts.push(`hc:${(nav as Navigator & { hardwareConcurrency?: number }).hardwareConcurrency ?? ''}`); + parts.push(`dm:${(nav as Navigator & { deviceMemory?: number }).deviceMemory ?? ''}`); + } + if (typeof screen !== 'undefined') { + parts.push(`scr:${screen.width}x${screen.height}x${screen.colorDepth}`); + } + if (typeof Intl !== 'undefined') { + try { + parts.push(`tz:${Intl.DateTimeFormat().resolvedOptions().timeZone ?? ''}`); + } catch { + /* resolvedOptions can throw in locked-down environments */ + } + } + parts.push(`tzo:${new Date().getTimezoneOffset()}`); + } catch { + /* a hostile/locked-down navigator must never break widget boot */ + } + return parts.join('|'); +} + +/** + * A small, fast, non-cryptographic string hash (FNV-1a, 32-bit) rendered as + * an unsigned hex string. Stable across runs for the same input. Not used for + * any security decision — only to derive a deterministic seed from the signal + * string above. + */ +function fnv1a(input: string): string { + let h = 0x811c9dc5; + for (let i = 0; i < input.length; i++) { + h ^= input.charCodeAt(i); + // 32-bit FNV prime multiply via shifts to stay in int range. + h = Math.imul(h, 0x01000193); + } + // >>> 0 → unsigned; pad to 8 hex chars. + return (h >>> 0).toString(16).padStart(8, '0'); +} + +/** Generate a UUID, preferring `crypto.randomUUID`, falling back to a v4-shaped string. */ +function generateUuid(): string { + try { + if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { + return crypto.randomUUID(); + } + } catch { + /* fall through to the manual generator */ + } + // RFC 4122 v4 shape from Math.random — only reached when crypto.randomUUID + // is unavailable (very old engines). Sufficient for a correlation token. + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { + const r = (Math.random() * 16) | 0; + const v = c === 'x' ? r : (r & 0x3) | 0x8; + return v.toString(16); + }); +} + +/** + * Compute a fresh fingerprint token: a stable random UUID, suffixed with the + * FNV hash of the device signals so the server can recover a soft device + * signal even if it only has the token. Called ONCE per browser (the result is + * persisted and reused) — see {@link getOrCreateFingerprint}. + */ +export function computeFingerprint(): string { + const signalHash = fnv1a(collectSignals()); + return `${generateUuid()}.${signalHash}`; +} + +/** + * Return the cached fingerprint if one was already computed for this browser, + * otherwise compute + persist a new one via the provided accessors. The + * persistence layer owns storage; this function owns the "compute once" policy. + */ +export function getOrCreateFingerprint(get: () => string | null, set: (fp: string) => void): string { + const existing = get(); + if (existing) return existing; + const fp = computeFingerprint(); + set(fp); + return fp; +} diff --git a/src/index.ts b/src/index.ts index 29f0ecb..4cbe437 100644 --- a/src/index.ts +++ b/src/index.ts @@ -33,9 +33,23 @@ export type { ChatWidgetConfig, ChatWidgetMode, ChatWidgetTheme } from './config export { initChatWidgetLoader } from './loader-core.js'; export { ConversationController, + httpBaseFromWsEndpoint, type ChatMessage, type Citation, type ConnectionStatus, type ConversationEvents, + type IdentityRestore, + type RestorableConversation, type Role, + type UserInfo, } from './conversation.js'; +export { + createWidgetStore, + PERSIST_VERSION, + storageKey, + type ConsentState, + type IdentityState, + type PersistedWidgetState, + type WidgetStore, +} from './persistence.js'; +export { computeFingerprint, getOrCreateFingerprint } from './fingerprint.js'; diff --git a/src/persistence.test.ts b/src/persistence.test.ts new file mode 100644 index 0000000..977e7df --- /dev/null +++ b/src/persistence.test.ts @@ -0,0 +1,166 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createWidgetStore, PERSIST_VERSION, type PersistedWidgetState, storageKey } from './persistence.js'; + +const AGENT = 'agent-123'; + +describe('persistence store', () => { + beforeEach(() => { + localStorage.clear(); + }); + afterEach(() => { + localStorage.clear(); + }); + + it('uses the per-agent localStorage key', () => { + expect(storageKey(AGENT)).toBe('smoo-chat-widget:agent-123'); + }); + + it('starts empty with the current version', () => { + const store = createWidgetStore(AGENT); + const s = store.getState(); + expect(s.version).toBe(PERSIST_VERSION); + expect(s.sessionId).toBeNull(); + expect(s.identity).toEqual({}); + expect(s.consent).toEqual({ emailOptIn: false, smsOptIn: false }); + expect(s.verifiedEmail).toBeNull(); + expect(s.verifiedEmailSessionId).toBeNull(); + expect(s.browserFingerprint).toBeNull(); + }); + + it('mergeIdentity only overwrites provided fields', () => { + const store = createWidgetStore(AGENT); + store.getState().mergeIdentity({ name: 'Ada', email: 'ada@example.com' }); + store.getState().mergeIdentity({ phone: '+15551234567' }); + expect(store.getState().identity).toEqual({ name: 'Ada', email: 'ada@example.com', phone: '+15551234567' }); + // undefined must NOT clobber an existing value. + store.getState().mergeIdentity({ name: undefined }); + expect(store.getState().identity.name).toBe('Ada'); + }); + + it('persists ONLY the pointer/identity/consent shape (never a transcript)', () => { + const store = createWidgetStore(AGENT); + store.getState().setSessionId('sess-1'); + store.getState().mergeIdentity({ name: 'Ada' }); + store.getState().setConsent({ emailOptIn: true, smsOptIn: false, consentSource: 'chat-widget-prechat', consentAt: '2026-01-01T00:00:00.000Z' }); + store.getState().setVerifiedEmail('ada@example.com'); + store.getState().setBrowserFingerprint('fp-abc'); + + const raw = localStorage.getItem(storageKey(AGENT)); + expect(raw).toBeTruthy(); + const parsed = JSON.parse(raw as string) as { state: PersistedWidgetState }; + const keys = Object.keys(parsed.state).sort(); + expect(keys).toEqual(['browserFingerprint', 'consent', 'identity', 'sessionId', 'verifiedEmail', 'verifiedEmailSessionId', 'version'].sort()); + // No message/transcript field leaks into storage. + expect(JSON.stringify(parsed.state)).not.toContain('messages'); + }); + + it('clearSession drops the pointer AND the session-scoped verifiedEmail, keeps identity/consent/fingerprint', () => { + const store = createWidgetStore(AGENT); + store.getState().setSessionId('sess-1'); + store.getState().mergeIdentity({ name: 'Ada' }); + store.getState().setConsent({ emailOptIn: true, smsOptIn: true }); + store.getState().setVerifiedEmail('ada@example.com', 'sess-1'); + store.getState().setBrowserFingerprint('fp-abc'); + + store.getState().clearSession(); + const s = store.getState(); + expect(s.sessionId).toBeNull(); + expect(s.identity.name).toBe('Ada'); + expect(s.consent.emailOptIn).toBe(true); + // verifiedEmail is a PER-SESSION OTP proof — it must NOT survive a clear, + // or it would leak onto the next (possibly different) visitor's session. + expect(s.verifiedEmail).toBeNull(); + expect(s.verifiedEmailSessionId).toBeNull(); + expect(s.browserFingerprint).toBe('fp-abc'); + }); + + it('setVerifiedEmail binds the proof to a session and clears the binding when nulled', () => { + const store = createWidgetStore(AGENT); + store.getState().setSessionId('sess-live'); + // Explicit session binding. + store.getState().setVerifiedEmail('ada@example.com', 'sess-X'); + expect(store.getState().verifiedEmail).toBe('ada@example.com'); + expect(store.getState().verifiedEmailSessionId).toBe('sess-X'); + // Omitted session falls back to the live pointer (never left unbound). + store.getState().setVerifiedEmail('bob@example.com'); + expect(store.getState().verifiedEmailSessionId).toBe('sess-live'); + // Nulling the email also clears the binding. + store.getState().setVerifiedEmail(null); + expect(store.getState().verifiedEmail).toBeNull(); + expect(store.getState().verifiedEmailSessionId).toBeNull(); + }); + + it('rehydrates a persisted blob on a fresh store (resume across reload)', () => { + const a = createWidgetStore(AGENT); + a.getState().setSessionId('sess-keep'); + a.getState().setBrowserFingerprint('fp-keep'); + // A brand-new store for the same agent reads the persisted blob. + const b = createWidgetStore(AGENT); + expect(b.getState().sessionId).toBe('sess-keep'); + expect(b.getState().browserFingerprint).toBe('fp-keep'); + }); + + it('migrates an unversioned/old blob into the current shape', () => { + // Simulate an old persisted blob with missing fields + a stray transcript. + localStorage.setItem( + storageKey(AGENT), + JSON.stringify({ state: { sessionId: 'old-sess', messages: [{ text: 'leak' }] }, version: 0 }), + ); + const store = createWidgetStore(AGENT); + const s = store.getState(); + expect(s.version).toBe(PERSIST_VERSION); + expect(s.sessionId).toBe('old-sess'); + // Missing fields backfilled to safe defaults. + expect(s.consent).toEqual({ emailOptIn: false, smsOptIn: false }); + expect(s.identity).toEqual({}); + // The stray transcript must not survive migration. + expect((s as unknown as Record).messages).toBeUndefined(); + }); + + it('falls back to an explicit in-memory store when localStorage throws (privacy mode), never touching real localStorage', () => { + // Privacy-mode: the probe write throws. The guard must hand zustand an + // explicit in-memory store — NOT return undefined (which makes zustand v5 + // re-engage its own createJSONStorage(()=>localStorage) and throw again). + const realSetItem = Storage.prototype.setItem; + const setSpy = vi.fn(() => { + throw new DOMException('denied', 'SecurityError'); + }); + Storage.prototype.setItem = setSpy as unknown as typeof realSetItem; + try { + // Construction must not throw despite setItem throwing on the probe. + const store = createWidgetStore('agent-privacy'); + // The store still works in memory. + store.getState().setSessionId('sess-mem'); + store.getState().mergeIdentity({ name: 'Ada' }); + expect(store.getState().sessionId).toBe('sess-mem'); + expect(store.getState().identity.name).toBe('Ada'); + // Nothing was persisted to real localStorage (the in-memory fallback + // never touches it). getItem returns null for this agent's key. + expect(localStorage.getItem(storageKey('agent-privacy'))).toBeNull(); + } finally { + Storage.prototype.setItem = realSetItem; + } + }); + + it('a fresh in-memory-fallback store does NOT see another store\'s persisted blob', () => { + // Persist a blob via the normal (working) localStorage path first. + const real = createWidgetStore(AGENT); + real.getState().setSessionId('sess-real'); + + // Now force the fallback for the SAME agent key. Because the fallback is a + // private Map (not real localStorage), it must start empty — proving it + // truly bypasses real localStorage rather than silently reading it. + const realGetItem = Storage.prototype.getItem; + const realSetItem = Storage.prototype.setItem; + Storage.prototype.setItem = (() => { + throw new DOMException('denied', 'SecurityError'); + }) as unknown as typeof realSetItem; + try { + const fallback = createWidgetStore(AGENT); + expect(fallback.getState().sessionId).toBeNull(); + } finally { + Storage.prototype.getItem = realGetItem; + Storage.prototype.setItem = realSetItem; + } + }); +}); diff --git a/src/persistence.ts b/src/persistence.ts new file mode 100644 index 0000000..a567e6a --- /dev/null +++ b/src/persistence.ts @@ -0,0 +1,271 @@ +/** + * Persisted widget state — the identity / consent / session-pointer client layer + * (ADR-048, SMOODEV-2129e). + * + * Built on Zustand's framework-agnostic `vanilla` store + the `persist` + * middleware (the user explicitly chose Zustand; it's ~1KB and has no React + * dependency, so it works inside the web component). The store is keyed per + * agent — localStorage key `smoo-chat-widget:` — so two agents embedded + * on the same origin don't clobber each other. + * + * ## What persists (and, deliberately, what does NOT) + * + * Only a **pointer** to the conversation plus the visitor's identity + marketing + * consent + verified email + browser fingerprint. The transcript is **never** + * persisted: the smooth-operator server is the source of truth and the widget + * re-hydrates history via `getMessages` on resume. This keeps localStorage small + * and avoids stale/divergent transcripts. + * + * `version` drives `persist.migrate` so future shape changes can upgrade old + * blobs in place instead of silently dropping them. + */ +import { createStore, type StoreApi } from 'zustand/vanilla'; +import { persist, type PersistStorage } from 'zustand/middleware'; + +/** Current persisted-shape version. Bump when the shape below changes incompatibly. */ +export const PERSIST_VERSION = 1 as const; + +/** Marketing-consent record captured at the pre-chat form. */ +export interface ConsentState { + emailOptIn: boolean; + smsOptIn: boolean; + /** Where the consent was captured. The widget always stamps `chat-widget-prechat`. */ + consentSource?: string; + /** ISO 8601 timestamp stamped when the visitor ticked a consent box. */ + consentAt?: string; +} + +/** Visitor identity collected from config or the pre-chat form. */ +export interface IdentityState { + name?: string; + email?: string; + phone?: string; +} + +/** + * The exact persisted shape (ADR-048 §d). Only the pointer + identity + consent + * persist — never the transcript. + */ +export interface PersistedWidgetState { + version: typeof PERSIST_VERSION; + /** Pointer to the active conversation session; cleared on ended/404. */ + sessionId: string | null; + identity: IdentityState; + consent: ConsentState; + /** + * Email proven via OTP for the CURRENT session. Session-scoped: cleared on + * `clearSession()` so it can't leak across visitors on a shared browser, and + * only threaded into session metadata when {@link verifiedEmailSessionId} + * matches the live session (i.e. on resume of the verified session) — never + * auto-stamped onto a brand-new session. + */ + verifiedEmail: string | null; + /** The sessionId the {@link verifiedEmail} was proven against (binds the proof to one session). */ + verifiedEmailSessionId: string | null; + /** Stable per-browser correlation token (see fingerprint.ts). */ + browserFingerprint: string | null; +} + +/** Store actions layered on top of the persisted state. */ +export interface WidgetStoreActions { + setSessionId: (sessionId: string | null) => void; + /** Merge identity fields (undefined values don't clobber existing ones). */ + mergeIdentity: (identity: IdentityState) => void; + /** Replace consent wholesale (the pre-chat form computes the full record). */ + setConsent: (consent: ConsentState) => void; + /** Record an OTP-proven email bound to a specific session. */ + setVerifiedEmail: (email: string | null, sessionId?: string | null) => void; + setBrowserFingerprint: (fp: string) => void; + /** + * Clear the session pointer (and the session-scoped `verifiedEmail` proof) but + * KEEP identity / consent / fingerprint — used when a persisted session has + * ended or 404s so the next turn starts a fresh session for a known visitor. + * `verifiedEmail` is deliberately cleared here: it is a per-session OTP proof, + * not a durable identity, so it must NOT survive onto a new session (which on a + * shared browser could be a different visitor). + */ + clearSession: () => void; +} + +export type WidgetStore = PersistedWidgetState & WidgetStoreActions; + +const EMPTY_CONSENT: ConsentState = { emailOptIn: false, smsOptIn: false }; + +function initialPersisted(): PersistedWidgetState { + return { + version: PERSIST_VERSION, + sessionId: null, + identity: {}, + consent: { ...EMPTY_CONSENT }, + verifiedEmail: null, + verifiedEmailSessionId: null, + browserFingerprint: null, + }; +} + +/** localStorage key for an agent's persisted widget state. */ +export function storageKey(agentId: string): string { + return `smoo-chat-widget:${agentId}`; +} + +/** + * An explicit in-memory (Map-backed) `PersistStorage`. Used as the fallback when + * real localStorage is unavailable. + * + * IMPORTANT: we must NOT return `undefined` from {@link safeStorage} in that case. + * zustand v5's `persist` treats a missing `storage` option by falling back to its + * OWN `createJSONStorage(() => localStorage)` — i.e. it re-engages the very + * localStorage the guard was trying to avoid (throwing again in privacy mode). + * Handing it this no-op storage keeps the store working purely in memory and + * guarantees the fallback can't touch real localStorage. + */ +function memoryStorage(): PersistStorage { + const mem = new Map(); + return { + getItem: (name) => { + const raw = mem.get(name); + if (!raw) return null; + try { + return JSON.parse(raw) as ReturnType['getItem']>; + } catch { + return null; + } + }, + setItem: (name, value) => { + mem.set(name, JSON.stringify(value)); + }, + removeItem: (name) => { + mem.delete(name); + }, + }; +} + +/** + * A `persist` storage adapter that tolerates the *absence* of localStorage + * (SSR, privacy-mode throwing on access, sandboxed iframes). When storage is + * unavailable the store still works in-memory; nothing is persisted, but the + * widget never throws on boot. + */ +function safeStorage(): PersistStorage { + let ls: Storage | null = null; + try { + ls = typeof localStorage !== 'undefined' ? localStorage : null; + // Probe: some environments expose localStorage but throw on access. + if (ls) { + const probe = '__smoo_probe__'; + ls.setItem(probe, '1'); + ls.removeItem(probe); + } + } catch { + ls = null; + } + // Fall back to an explicit in-memory store — NEVER `undefined` (see memoryStorage). + if (!ls) return memoryStorage(); + const storage = ls; + return { + getItem: (name) => { + try { + const raw = storage.getItem(name); + return raw ? (JSON.parse(raw) as ReturnType['getItem']>) : null; + } catch { + return null; + } + }, + setItem: (name, value) => { + try { + storage.setItem(name, JSON.stringify(value)); + } catch { + /* quota / privacy-mode write failures are non-fatal */ + } + }, + removeItem: (name) => { + try { + storage.removeItem(name); + } catch { + /* non-fatal */ + } + }, + }; +} + +/** + * Migrate a persisted blob from an older `version` to the current shape. Today + * v1 is the only version, so this just backfills any missing fields onto an + * unknown old blob; future versions add `case` branches here. + */ +function migrate(persisted: unknown): PersistedWidgetState { + const base = initialPersisted(); + if (!persisted || typeof persisted !== 'object') return base; + const p = persisted as Partial; + return { + version: PERSIST_VERSION, + sessionId: typeof p.sessionId === 'string' ? p.sessionId : null, + identity: { + name: typeof p.identity?.name === 'string' ? p.identity.name : undefined, + email: typeof p.identity?.email === 'string' ? p.identity.email : undefined, + phone: typeof p.identity?.phone === 'string' ? p.identity.phone : undefined, + }, + consent: { + emailOptIn: p.consent?.emailOptIn === true, + smsOptIn: p.consent?.smsOptIn === true, + consentSource: typeof p.consent?.consentSource === 'string' ? p.consent.consentSource : undefined, + consentAt: typeof p.consent?.consentAt === 'string' ? p.consent.consentAt : undefined, + }, + verifiedEmail: typeof p.verifiedEmail === 'string' ? p.verifiedEmail : null, + verifiedEmailSessionId: typeof p.verifiedEmailSessionId === 'string' ? p.verifiedEmailSessionId : null, + browserFingerprint: typeof p.browserFingerprint === 'string' ? p.browserFingerprint : null, + }; +} + +/** + * Create the per-agent persisted Zustand store. The `partialize` step ensures + * only the persisted shape (never any future transient action/UI state) is + * written to localStorage. + */ +export function createWidgetStore(agentId: string): StoreApi { + return createStore()( + persist( + (set) => ({ + ...initialPersisted(), + setSessionId: (sessionId) => set({ sessionId }), + mergeIdentity: (identity) => + set((state) => ({ + identity: { + ...state.identity, + ...(identity.name !== undefined ? { name: identity.name } : {}), + ...(identity.email !== undefined ? { email: identity.email } : {}), + ...(identity.phone !== undefined ? { phone: identity.phone } : {}), + }, + })), + setConsent: (consent) => set({ consent: { ...consent } }), + setVerifiedEmail: (verifiedEmail, sessionId) => + set((state) => ({ + verifiedEmail, + // Bind the proof to the session it was verified against. When the + // caller omits sessionId, fall back to the live pointer so the + // proof is never left unbound. + verifiedEmailSessionId: verifiedEmail === null ? null : (sessionId ?? state.sessionId), + })), + setBrowserFingerprint: (browserFingerprint) => set({ browserFingerprint }), + // Drop the pointer AND the session-scoped OTP proof; keep durable identity. + clearSession: () => set({ sessionId: null, verifiedEmail: null, verifiedEmailSessionId: null }), + }), + { + name: storageKey(agentId), + version: PERSIST_VERSION, + storage: safeStorage(), + migrate, + // Persist ONLY the data shape — never the action functions. + partialize: (state): PersistedWidgetState => ({ + version: PERSIST_VERSION, + sessionId: state.sessionId, + identity: state.identity, + consent: state.consent, + verifiedEmail: state.verifiedEmail, + verifiedEmailSessionId: state.verifiedEmailSessionId, + browserFingerprint: state.browserFingerprint, + }), + }, + ), + ); +} diff --git a/src/styles.ts b/src/styles.ts index d19c802..1e7b7eb 100644 --- a/src/styles.ts +++ b/src/styles.ts @@ -530,6 +530,17 @@ export function buildStyles(theme: ResolvedTheme, mode: ChatWidgetMode = 'popove } .pc-submit:hover { transform: translateY(-1px); } .pc-submit:active { transform: scale(.98); } +.pc-consents { display: flex; flex-direction: column; gap: 9px; margin-top: 2px; } +.pc-consent { display: flex; align-items: flex-start; gap: 9px; cursor: pointer; } +.pc-consent input { + margin-top: 2px; + width: 16px; + height: 16px; + flex: 0 0 auto; + accent-color: var(--sac-primary); + cursor: pointer; +} +.pc-consent span { font-size: 12px; line-height: 1.4; color: color-mix(in srgb, var(--sac-text) 72%, transparent); } /* ─────────────────── Starter-prompt chips ─────────────────────────── */ .prompts { display: flex; flex-wrap: wrap; gap: 8px; margin: 2px 0 2px 35px; } @@ -608,6 +619,61 @@ export function buildStyles(theme: ResolvedTheme, mode: ChatWidgetMode = 'popove .int-row .int-btn { flex: 1; } .int-row .int-input + .int-btn { flex: 0 0 auto; } .int-error { margin-top: 8px; font-size: 12px; color: #f87171; } +.int-card { position: relative; } +.int-close { + position: absolute; + top: 8px; + right: 9px; + border: none; + background: transparent; + color: color-mix(in srgb, var(--sac-text) 55%, transparent); + font-size: 18px; + line-height: 1; + cursor: pointer; + padding: 2px 4px; + border-radius: 6px; + transition: color .2s ease, background .2s ease; +} +.int-close:hover { color: var(--sac-text); background: color-mix(in srgb, var(--sac-text) 8%, transparent); } + +/* ─────────────── Cross-device "Restore my chats" ──────────────────── */ +.restore-link { + border: none; + background: none; + padding: 0; + font: inherit; + font-size: 10.5px; + letter-spacing: .04em; + color: color-mix(in srgb, var(--sac-primary) 80%, var(--sac-text)); + cursor: pointer; + text-decoration: underline; + text-underline-offset: 2px; +} +.restore-link:hover { color: var(--sac-primary); } +.restore-list { display: flex; flex-direction: column; gap: 7px; margin-top: 9px; } +.restore-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + text-align: left; + border: 1px solid color-mix(in srgb, var(--sac-border) 80%, transparent); + background: var(--sac-bg); + color: var(--sac-text); + border-radius: 10px; + padding: 9px 11px; + font-family: inherit; + font-size: 12.5px; + cursor: pointer; + transition: border-color .2s ease, background .2s ease, transform .2s ease; +} +.restore-item:hover { + border-color: color-mix(in srgb, var(--sac-primary) 50%, transparent); + background: color-mix(in srgb, var(--sac-primary) 8%, var(--sac-bg)); + transform: translateY(-1px); +} +.restore-preview { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.restore-when { flex: 0 0 auto; font-size: 11px; color: color-mix(in srgb, var(--sac-text) 55%, transparent); } .hidden { display: none !important; } diff --git a/tsdown.config.ts b/tsdown.config.ts index 259cf50..8a1c98c 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -45,10 +45,18 @@ export default defineConfig([ platform: 'browser', target: browserTarget, globalName: 'SmoothAgentChat', - deps: { alwaysBundle: [/@smooai\/smooth-operator/] }, + // Bundle the protocol client AND zustand into the standalone global — a + // plain `