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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .changeset/identity-persistence-consent.md
Original file line number Diff line number Diff line change
@@ -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:<agentId>`): 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.
500 changes: 500 additions & 0 deletions e2e/identity-persistence-mock.spec.ts

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
23 changes: 23 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

32 changes: 31 additions & 1 deletion src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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). */
Expand All @@ -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.
Expand All @@ -102,11 +127,12 @@ export interface ChatWidgetConfig {
/** The fully-resolved theme (canonical keys only — aliases are folded in). */
export type ResolvedTheme = Required<Omit<ChatWidgetTheme, 'chatBubbleInbound' | 'chatBubbleInboundText' | 'chatBubbleOutbound' | 'chatBubbleOutboundText'>>;

export type ResolvedConfig = Required<Omit<ChatWidgetConfig, 'theme' | 'userName' | 'userEmail' | 'userPhone'>> & {
export type ResolvedConfig = Required<Omit<ChatWidgetConfig, 'theme' | 'userName' | 'userEmail' | 'userPhone' | 'authContext'>> & {
theme: ResolvedTheme;
userName?: string;
userEmail?: string;
userPhone?: string;
authContext?: { userId: string; signature: string; timestamp: number };
};

/** Resolve a partial config against the built-in defaults. */
Expand All @@ -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.",
Expand All @@ -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',
Expand Down
Loading
Loading