@smooai/chat-widget 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -8,15 +8,54 @@
8
8
  * so the swap is purely at the client-library boundary.
9
9
  *
10
10
  * Flow:
11
- * 1. `connect()` → opens the WebSocket transport and `create_conversation_session`.
11
+ * 1. `connect()` → opens the WebSocket transport and `create_conversation_session`
12
+ * (or RESUMES a persisted session via `get_session`/`get_messages`).
12
13
  * 2. `send(text)` → `send_message`, streaming `stream_token` deltas into the
13
14
  * in-progress assistant message, then the terminal
14
15
  * `eventual_response`.
15
16
  *
17
+ * 0.7.0 (SMOODEV-2129e) adds the identity / persistence / consent client layer:
18
+ * - `browserFingerprint` computed once + sent on every `create_conversation_session`.
19
+ * - identity + marketing consent threaded into the session `metadata`.
20
+ * - same-session RESUME on load (no engine change — `get_session` + `get_messages`).
21
+ * - returning-visitor RESUME via `POST /internal/resume-by-fingerprint`, and
22
+ * cross-device "restore my chats" via the `POST /internal/identity/{request-otp,
23
+ * verify-otp,resolve}` routes on the chat-ws wrapper. The engine (smooth-operator
24
+ * 1.8.0) owns the `/ws` dispatch and REJECTS unknown verbs, so these are HTTP
25
+ * `fetch()` calls (origin-allowlisted + optional authContext, per ADR-046/048) —
26
+ * NOT WS frames.
27
+ *
16
28
  * The controller is UI-agnostic: it emits typed events and the view renders them.
17
29
  */
18
30
  import { type Citation, ProtocolError, type ServerEvent, SmoothAgentClient } from '@smooai/smooth-operator';
31
+ import type { StoreApi } from 'zustand/vanilla';
19
32
  import type { ChatWidgetConfig } from './config.js';
33
+ import { getOrCreateFingerprint } from './fingerprint.js';
34
+ import { type ConsentState, createWidgetStore, type WidgetStore } from './persistence.js';
35
+
36
+ /**
37
+ * Derive the HTTP base for the chat-ws wrapper's `/internal/*` REST routes from
38
+ * the WS endpoint: `wss://ai.smoo.ai/ws` → `https://ai.smoo.ai`. The engine
39
+ * (smooth-operator 1.8.0) owns the `/ws` dispatch and REJECTS unknown verbs, so
40
+ * the cross-device identity flow + fingerprint resume are HTTP POST routes on the
41
+ * wrapper (origin-allowlisted + authContext, per ADR-046/ADR-048) — NOT WS frames.
42
+ */
43
+ export function httpBaseFromWsEndpoint(endpoint: string): string | null {
44
+ try {
45
+ const u = new URL(endpoint);
46
+ u.protocol = u.protocol === 'ws:' ? 'http:' : u.protocol === 'wss:' ? 'https:' : u.protocol;
47
+ // The REST routes live at the host root (`/internal/*`), not under `/ws`.
48
+ return `${u.protocol}//${u.host}`;
49
+ } catch {
50
+ // FAIL LOUD on a non-absolute endpoint. A relative fallback (e.g.
51
+ // `string.replace`) would yield a relative base, and `fetch(\`${base}/internal/...\`)`
52
+ // would then POST identity/OTP data to the HOST page origin (e.g. smoo.ai)
53
+ // instead of the operator host (ai.smoo.ai) — leaking it to the wrong origin.
54
+ // Returning null forces the controller into an error state and refuses the
55
+ // `/internal/*` calls rather than mis-targeting them.
56
+ return null;
57
+ }
58
+ }
20
59
 
21
60
  export type { Citation };
22
61
 
@@ -66,8 +105,35 @@ export interface UserInfo {
66
105
  name?: string;
67
106
  email?: string;
68
107
  phone?: string;
108
+ /** Marketing-consent opt-ins captured at the pre-chat form (ADR-048). */
109
+ consent?: { emailOptIn: boolean; smsOptIn: boolean };
110
+ }
111
+
112
+ /** One conversation surfaced by `resolve_identity` for the cross-device picker. */
113
+ export interface RestorableConversation {
114
+ conversationId: string;
115
+ sessionId: string;
116
+ lastActivityAt?: string;
117
+ preview?: string;
69
118
  }
70
119
 
120
+ /**
121
+ * State machine for the cross-device "restore my chats" flow. Driven by the three
122
+ * HTTP POST routes on the chat-ws wrapper — `/internal/identity/request-otp` →
123
+ * `/internal/identity/verify-otp` → `/internal/identity/resolve` (ADR-048 §c).
124
+ * The view renders a panel off this.
125
+ */
126
+ export type IdentityRestore =
127
+ | { phase: 'idle' }
128
+ /** UI-local: the email-entry step before any request is sent. */
129
+ | { phase: 'awaiting_email'; error?: string }
130
+ | { phase: 'requesting'; email: string; channel: 'email' | 'sms' }
131
+ | { phase: 'awaiting_code'; email: string; channel: 'email' | 'sms'; maskedDestination?: string; error?: string; attemptsRemaining?: number }
132
+ | { phase: 'verifying'; email: string; channel: 'email' | 'sms' }
133
+ | { phase: 'resolving'; email: string }
134
+ | { phase: 'resolved'; email: string; conversations: RestorableConversation[] }
135
+ | { phase: 'error'; message: string };
136
+
71
137
  export interface ConversationEvents {
72
138
  /** Fired whenever the message list changes (append, token delta, finalize). */
73
139
  onMessages: (messages: ChatMessage[]) => void;
@@ -75,6 +141,8 @@ export interface ConversationEvents {
75
141
  onStatus: (status: ConnectionStatus, detail?: string) => void;
76
142
  /** Fired when a turn pauses for OTP / tool-confirmation, and `null` when it clears. */
77
143
  onInterrupt?: (interrupt: Interrupt | null) => void;
144
+ /** Fired on cross-device identity-restore state transitions. */
145
+ onIdentityRestore?: (state: IdentityRestore) => void;
78
146
  }
79
147
 
80
148
  /** Pull the final assistant text out of an `eventual_response` data payload. */
@@ -115,33 +183,110 @@ function extractCitations(inner: unknown): Citation[] {
115
183
  return out;
116
184
  }
117
185
 
186
+ /** A `get_conversation_messages` row, narrowed defensively off the wire. */
187
+ interface WireMessage {
188
+ id?: string;
189
+ direction?: 'inbound' | 'outbound';
190
+ content?: { text?: string };
191
+ createdAt?: string;
192
+ }
193
+
194
+ /** Convert a server message row into a finalized {@link ChatMessage}. */
195
+ function wireMessageToChat(m: WireMessage, idx: number): ChatMessage | null {
196
+ const text = typeof m.content?.text === 'string' ? m.content.text : '';
197
+ if (!text) return null;
198
+ const role: Role = m.direction === 'outbound' ? 'assistant' : 'user';
199
+ return { id: typeof m.id === 'string' ? m.id : `hist-${idx}`, role, text, streaming: false };
200
+ }
201
+
118
202
  export class ConversationController {
119
203
  private readonly config: ChatWidgetConfig;
120
204
  private readonly events: ConversationEvents;
205
+ private readonly store: StoreApi<WidgetStore>;
121
206
  private client: SmoothAgentClient | null = null;
122
207
  private sessionId: string | null = null;
123
208
  private readonly messages: ChatMessage[] = [];
124
209
  private status: ConnectionStatus = 'idle';
125
210
  private seq = 0;
126
- /** Visitor identity, seeded from config and updated by the pre-chat form. */
127
- private identity: UserInfo;
128
211
  /** requestId of the in-flight turn — used to resume OTP / tool confirmations. */
129
212
  private activeRequestId: string | null = null;
130
213
  private interrupt: Interrupt | null = null;
214
+ private identityRestore: IdentityRestore = { phase: 'idle' };
215
+ /**
216
+ * True once the resume probe (persisted-pointer get_session OR the
217
+ * `/internal/resume-by-fingerprint` POST) has run for this controller. Makes
218
+ * `connect()` idempotent: re-entering after a transient `error` status (e.g. a
219
+ * retried `send()`) creates a fresh session rather than re-running the resume
220
+ * probe — which would fire another `resumeByFingerprint` POST and could adopt a
221
+ * session we already decided not to resume.
222
+ */
223
+ private resumeAttempted = false;
224
+ /**
225
+ * HTTP base for the chat-ws wrapper's `/internal/*` REST routes. `null` when
226
+ * the configured WS endpoint could not be parsed into an absolute origin — in
227
+ * that case the `/internal/*` routes are refused (rather than mis-targeted at
228
+ * the host page origin). See {@link httpBaseFromWsEndpoint}.
229
+ */
230
+ private readonly httpBase: string | null;
131
231
 
132
- constructor(config: ChatWidgetConfig, events: ConversationEvents) {
232
+ constructor(config: ChatWidgetConfig, events: ConversationEvents, store?: StoreApi<WidgetStore>) {
133
233
  this.config = config;
134
234
  this.events = events;
135
- this.identity = { name: config.userName, email: config.userEmail, phone: config.userPhone };
235
+ this.httpBase = httpBaseFromWsEndpoint(config.endpoint);
236
+ if (this.httpBase === null) {
237
+ // A non-absolute endpoint means the identity / resume `/internal/*` routes
238
+ // have no safe target. Flag the controller in error so the UI surfaces it
239
+ // and the routes refuse (see postInternal) rather than mis-targeting the
240
+ // host page origin. Deferred to a microtask so listeners attached after
241
+ // construction still observe the transition.
242
+ queueMicrotask(() => this.setStatus('error', `Invalid chat endpoint: ${config.endpoint}`));
243
+ }
244
+ this.store = store ?? createWidgetStore(config.agentId);
245
+ // Seed identity from config into the persisted store. `mergeIdentity` is
246
+ // applied on EVERY construct, so a config-provided field always wins over
247
+ // the persisted value (config is authoritative when present). Fields the
248
+ // config does NOT provide keep their persisted value — those survive across
249
+ // reloads; explicitly-configured ones are re-applied each load.
250
+ const seed: { name?: string; email?: string; phone?: string } = {};
251
+ if (config.userName) seed.name = config.userName;
252
+ if (config.userEmail) seed.email = config.userEmail;
253
+ if (config.userPhone) seed.phone = config.userPhone;
254
+ if (Object.keys(seed).length > 0) this.store.getState().mergeIdentity(seed);
136
255
  }
137
256
 
138
257
  get connectionStatus(): ConnectionStatus {
139
258
  return this.status;
140
259
  }
141
260
 
142
- /** Merge in visitor identity (from the pre-chat form). Applied on next connect. */
261
+ /** The persisted store, exposed so the view can read identity for the pre-chat gate. */
262
+ getStore(): StoreApi<WidgetStore> {
263
+ return this.store;
264
+ }
265
+
266
+ /** True when a persisted session pointer exists (drives the resume path). */
267
+ hasPersistedSession(): boolean {
268
+ return !!this.store.getState().sessionId;
269
+ }
270
+
271
+ /** True when persisted identity exists (lets the view skip the pre-chat form). */
272
+ hasPersistedIdentity(): boolean {
273
+ const id = this.store.getState().identity;
274
+ return !!(id.name || id.email || id.phone);
275
+ }
276
+
277
+ /** Merge in visitor identity + consent (from the pre-chat form). Applied on next connect. */
143
278
  setUserInfo(info: UserInfo): void {
144
- this.identity = { ...this.identity, ...info };
279
+ const { name, email, phone, consent } = info;
280
+ this.store.getState().mergeIdentity({ name, email, phone });
281
+ if (consent) {
282
+ const consentAt = consent.emailOptIn || consent.smsOptIn ? new Date().toISOString() : undefined;
283
+ this.store.getState().setConsent({
284
+ emailOptIn: consent.emailOptIn,
285
+ smsOptIn: consent.smsOptIn,
286
+ consentSource: 'chat-widget-prechat',
287
+ consentAt,
288
+ });
289
+ }
145
290
  }
146
291
 
147
292
  private setInterrupt(interrupt: Interrupt | null): void {
@@ -149,6 +294,15 @@ export class ConversationController {
149
294
  this.events.onInterrupt?.(interrupt);
150
295
  }
151
296
 
297
+ private setIdentityRestore(state: IdentityRestore): void {
298
+ this.identityRestore = state;
299
+ this.events.onIdentityRestore?.(state);
300
+ }
301
+
302
+ get currentIdentityRestore(): IdentityRestore {
303
+ return this.identityRestore;
304
+ }
305
+
152
306
  /** Submit an OTP code to resume the paused turn. No-op if not awaiting OTP. */
153
307
  verifyOtp(code: string): void {
154
308
  if (!this.client || !this.sessionId || !this.activeRequestId || this.interrupt?.kind !== 'otp') return;
@@ -177,21 +331,115 @@ export class ConversationController {
177
331
  this.events.onMessages(this.messages.map((m) => ({ ...m })));
178
332
  }
179
333
 
180
- /** Open the transport and create a conversation session. Idempotent. */
334
+ /** Compute (once) + return the persisted browser fingerprint. */
335
+ private fingerprint(): string {
336
+ const state = this.store.getState();
337
+ return getOrCreateFingerprint(
338
+ () => state.browserFingerprint,
339
+ (fp) => this.store.getState().setBrowserFingerprint(fp),
340
+ );
341
+ }
342
+
343
+ /**
344
+ * Build the `metadata` payload threaded into `create_conversation_session`:
345
+ * phone (no first-class engine field) and consent.
346
+ *
347
+ * NOTE: `verifiedEmail` is deliberately NOT stamped here. It is a per-session
348
+ * OTP proof bound to the session it was verified against
349
+ * (`verifiedEmailSessionId`), and the server only treats an actual OTP `verify`
350
+ * as proof — metadata `verifiedEmail` is just a hint. Auto-stamping it onto
351
+ * every brand-new `create_conversation_session` would mislabel a fresh
352
+ * visitor's session with a prior (possibly different) visitor's email on a
353
+ * shared browser. The verified email is only used when RESUMING the exact
354
+ * session it was proven for — see {@link verifiedEmailForSession}.
355
+ */
356
+ private sessionMetadata(): Record<string, unknown> | undefined {
357
+ const state = this.store.getState();
358
+ const meta: Record<string, unknown> = {};
359
+ if (state.identity.phone) meta.userPhone = state.identity.phone;
360
+ const consent = state.consent;
361
+ if (consent.emailOptIn || consent.smsOptIn || consent.consentAt) {
362
+ const c: ConsentState = {
363
+ emailOptIn: consent.emailOptIn,
364
+ smsOptIn: consent.smsOptIn,
365
+ consentSource: consent.consentSource ?? 'chat-widget-prechat',
366
+ };
367
+ if (consent.consentAt) c.consentAt = consent.consentAt;
368
+ meta.consent = c;
369
+ }
370
+ return Object.keys(meta).length > 0 ? meta : undefined;
371
+ }
372
+
373
+ /**
374
+ * The verified-email hint, but ONLY when the OTP proof is bound to the session
375
+ * being resumed (`verifiedEmailSessionId === sessionId`). Returns null
376
+ * otherwise so a stale/cross-visitor proof is never threaded onto a session it
377
+ * wasn't proven for.
378
+ */
379
+ private verifiedEmailForSession(sessionId: string): string | null {
380
+ const state = this.store.getState();
381
+ if (state.verifiedEmail && state.verifiedEmailSessionId === sessionId) {
382
+ return state.verifiedEmail;
383
+ }
384
+ return null;
385
+ }
386
+
387
+ /** Lazily open the WS client (default transport). Idempotent within a connect. */
388
+ private async ensureClient(): Promise<void> {
389
+ if (this.client) return;
390
+ this.client = new SmoothAgentClient({ url: this.config.endpoint });
391
+ await this.client.connect();
392
+ }
393
+
394
+ /**
395
+ * Open the connection and either RESUME or create a session.
396
+ *
397
+ * 1. Persisted pointer (ADR-048 §b): `get_session` → if not `ended`, reuse +
398
+ * hydrate from `get_messages` (newest-first, reversed). On ended/404 clear
399
+ * ONLY the pointer (identity/consent survive).
400
+ * 2. No persisted pointer: POST `/internal/resume-by-fingerprint` FIRST; if
401
+ * `resumable`, adopt the returned session (the wrapper has primed the
402
+ * operator registry), reuse the sessionId, and hydrate via get_session/
403
+ * get_messages — rather than relying on createConversationSession to resume.
404
+ * 3. Otherwise create a fresh session.
405
+ */
181
406
  async connect(): Promise<void> {
182
407
  if (this.status === 'connecting' || this.status === 'ready') return;
183
408
  this.setStatus('connecting');
184
409
  try {
185
- this.client = new SmoothAgentClient({ url: this.config.endpoint });
186
- await this.client.connect();
187
- const session = await this.client.createConversationSession({
188
- agentId: this.config.agentId,
189
- userName: this.identity.name,
190
- userEmail: this.identity.email,
191
- // Phone has no first-class field yet; carry it in session metadata.
192
- ...(this.identity.phone ? { metadata: { userPhone: this.identity.phone } } : {}),
193
- });
194
- this.sessionId = session.sessionId;
410
+ await this.ensureClient();
411
+ // The resume probe (persisted-pointer get_session OR the fingerprint
412
+ // resume POST) runs AT MOST ONCE per controller lifecycle. Re-entering
413
+ // connect() after a transient error (e.g. a retried send()) must not
414
+ // re-run the probe — that would re-fire resumeByFingerprint and could
415
+ // adopt a session we already chose not to resume. After the first
416
+ // attempt, fall straight through to creating a fresh session.
417
+ if (!this.resumeAttempted) {
418
+ this.resumeAttempted = true;
419
+ const persistedSessionId = this.store.getState().sessionId;
420
+ if (persistedSessionId) {
421
+ const resumed = await this.tryResume(persistedSessionId);
422
+ if (resumed) {
423
+ this.setStatus('ready');
424
+ return;
425
+ }
426
+ // Resume failed (ended/404/gone) — clear the pointer, keep identity.
427
+ this.store.getState().clearSession();
428
+ } else {
429
+ // Returning anonymous visitor with no stored pointer: ask the
430
+ // wrapper to resolve a recent session for this fingerprint.
431
+ const fpSessionId = await this.resumeByFingerprint();
432
+ if (fpSessionId) {
433
+ const resumed = await this.tryResume(fpSessionId);
434
+ if (resumed) {
435
+ this.store.getState().setSessionId(fpSessionId);
436
+ this.setStatus('ready');
437
+ return;
438
+ }
439
+ }
440
+ }
441
+ }
442
+ await this.createSession();
195
443
  this.setStatus('ready');
196
444
  } catch (err) {
197
445
  this.setStatus('error', err instanceof Error ? err.message : String(err));
@@ -199,6 +447,128 @@ export class ConversationController {
199
447
  }
200
448
  }
201
449
 
450
+ // ─────────────────────── chat-ws `/internal/*` HTTP ─────────────────────────
451
+
452
+ /**
453
+ * Build the auth fields every `/internal/*` route shares: `agentId` (required
454
+ * for the agent-policy lookup), `agentName` (used as the OTP email sender), and
455
+ * the optional pre-auth `authContext` the host page may have configured. The
456
+ * `Origin` header is sent automatically by the browser and checked server-side.
457
+ */
458
+ private authBody(): Record<string, unknown> {
459
+ const body: Record<string, unknown> = { agentId: this.config.agentId };
460
+ if (this.config.agentName) body.agentName = this.config.agentName;
461
+ if (this.config.authContext) body.authContext = this.config.authContext;
462
+ return body;
463
+ }
464
+
465
+ /** POST JSON to a `/internal/*` route; returns the parsed body (or throws). */
466
+ private async postInternal(path: string, payload: Record<string, unknown>): Promise<Record<string, unknown>> {
467
+ if (this.httpBase === null) {
468
+ // No absolute origin could be derived from the WS endpoint — refuse the
469
+ // call loudly rather than POST identity data to a relative (host-page) URL.
470
+ throw new Error(`Cannot reach ${path}: the chat endpoint is not an absolute URL.`);
471
+ }
472
+ const res = await fetch(`${this.httpBase}${path}`, {
473
+ method: 'POST',
474
+ headers: { 'content-type': 'application/json' },
475
+ // Auth is the `Origin` allowlist + the `authContext` body field — NOT
476
+ // cookies. `credentials: 'include'` would force the server to reply with
477
+ // `Access-Control-Allow-Credentials: true` AND a reflected origin (a
478
+ // wildcard `*` is illegal with credentials), so a plain origin-allowlisted
479
+ // CORS config would fail the preflight and break EVERY `/internal/*` call.
480
+ // Omit credentials so the cross-origin POST works against an allowlist
481
+ // that doesn't (and shouldn't need to) opt into credentialed CORS.
482
+ credentials: 'omit',
483
+ body: JSON.stringify({ ...this.authBody(), ...payload }),
484
+ });
485
+ let json: Record<string, unknown> = {};
486
+ try {
487
+ json = (await res.json()) as Record<string, unknown>;
488
+ } catch {
489
+ json = {};
490
+ }
491
+ if (!res.ok) {
492
+ const err = json.error as { code?: string; message?: string } | undefined;
493
+ throw new Error(err?.message ?? `${path} failed (${res.status})`);
494
+ }
495
+ return json;
496
+ }
497
+
498
+ /**
499
+ * POST `/internal/resume-by-fingerprint`. Returns the resumable sessionId when
500
+ * the wrapper found (and primed) a recent session for this fingerprint, else
501
+ * null. Network/route failures are swallowed → null (fall through to create).
502
+ */
503
+ private async resumeByFingerprint(): Promise<string | null> {
504
+ try {
505
+ const json = await this.postInternal('/internal/resume-by-fingerprint', { browserFingerprint: this.fingerprint() });
506
+ if (json.resumable === true && typeof json.sessionId === 'string') {
507
+ return json.sessionId;
508
+ }
509
+ } catch {
510
+ // Resume is best-effort; any failure just means a fresh session.
511
+ }
512
+ return null;
513
+ }
514
+
515
+ /** `create_conversation_session` with fingerprint + identity + consent metadata. */
516
+ private async createSession(): Promise<void> {
517
+ if (!this.client) throw new Error('Conversation is not connected');
518
+ const state = this.store.getState();
519
+ const metadata = this.sessionMetadata();
520
+ const session = await this.client.createConversationSession({
521
+ agentId: this.config.agentId,
522
+ userName: state.identity.name,
523
+ userEmail: state.identity.email,
524
+ browserFingerprint: this.fingerprint(),
525
+ ...(metadata ? { metadata } : {}),
526
+ });
527
+ this.sessionId = session.sessionId;
528
+ this.store.getState().setSessionId(session.sessionId);
529
+ }
530
+
531
+ /**
532
+ * Attempt to resume `sessionId`: returns true and hydrates the transcript when
533
+ * the session is live, false when it has ended / can't be fetched.
534
+ */
535
+ private async tryResume(sessionId: string): Promise<boolean> {
536
+ if (!this.client) return false;
537
+ let snap: { status?: 'active' | 'idle' | 'ended' };
538
+ try {
539
+ snap = await this.client.getSession({ sessionId });
540
+ } catch {
541
+ return false; // 404 / SESSION_NOT_FOUND / network — start fresh.
542
+ }
543
+ if (snap.status === 'ended') return false;
544
+
545
+ this.sessionId = sessionId;
546
+ await this.hydrateHistory(sessionId);
547
+ return true;
548
+ }
549
+
550
+ /** Page recent history (newest-first), reverse to chronological, and render. */
551
+ private async hydrateHistory(sessionId: string): Promise<void> {
552
+ if (!this.client) return;
553
+ try {
554
+ const page = await this.client.getMessages({ sessionId, limit: 50 });
555
+ const rows = Array.isArray(page.messages) ? page.messages : [];
556
+ // The server returns newest-first; reverse to chronological for the UI.
557
+ const chronological = [...rows].reverse();
558
+ const hydrated: ChatMessage[] = [];
559
+ chronological.forEach((m, i) => {
560
+ const chat = wireMessageToChat(m as WireMessage, i);
561
+ if (chat) hydrated.push(chat);
562
+ });
563
+ this.messages.length = 0;
564
+ this.messages.push(...hydrated);
565
+ this.emitMessages();
566
+ } catch {
567
+ // History fetch is best-effort: a resumable session with no fetchable
568
+ // history just shows an empty transcript rather than failing the resume.
569
+ }
570
+ }
571
+
202
572
  /**
203
573
  * Submit a user message. Appends the user bubble immediately, then streams the
204
574
  * assistant reply token-by-token, finalizing on `eventual_response`.
@@ -309,12 +679,155 @@ export class ConversationController {
309
679
  }
310
680
  }
311
681
 
682
+ // ─────────────────── Cross-device "restore my chats" (§c) ───────────────────
683
+ //
684
+ // Three HTTP POST routes on the chat-ws wrapper (the engine `/ws` dispatch
685
+ // rejects unknown verbs): request-otp → verify-otp → resolve. ALL THREE are
686
+ // session-scoped — they require a live `sessionId` (a uuid). request-otp
687
+ // establishes the session itself (idempotent connect) before sending, so the
688
+ // whole flow shares one session and verify-otp can't hit "No active session"
689
+ // even if the email was submitted before the initial connect() resolved.
690
+
691
+ /**
692
+ * Begin the cross-device restore: POST `/internal/identity/request-otp` for
693
+ * `email` over `channel`. The view collects the email via an explicit affordance.
694
+ */
695
+ async requestIdentityOtp(email: string, channel: 'email' | 'sms' = 'email'): Promise<void> {
696
+ const trimmed = email.trim();
697
+ if (!trimmed) return;
698
+ this.setIdentityRestore({ phase: 'requesting', email: trimmed, channel });
699
+ // request-otp must be SESSION-CONSISTENT with verify-otp (which hard-requires
700
+ // a sessionId). If the restore affordance fired request-otp before connect()
701
+ // resolved, there'd be no sessionId here and verify-otp would later error
702
+ // "No active session." Establish a session first (idempotent connect), then
703
+ // require it — so the whole request → verify flow shares one live session.
704
+ if (!this.sessionId) {
705
+ try {
706
+ await this.connect();
707
+ } catch {
708
+ /* fall through: handled by the sessionId check below */
709
+ }
710
+ }
711
+ if (!this.sessionId) {
712
+ this.setIdentityRestore({ phase: 'error', message: 'No active session to verify against.' });
713
+ return;
714
+ }
715
+ try {
716
+ const json = await this.postInternal('/internal/identity/request-otp', {
717
+ sessionId: this.sessionId,
718
+ email: trimmed,
719
+ channel,
720
+ });
721
+ const masked = typeof json.maskedDestination === 'string' ? json.maskedDestination : undefined;
722
+ this.setIdentityRestore({ phase: 'awaiting_code', email: trimmed, channel, maskedDestination: masked });
723
+ } catch (err) {
724
+ this.setIdentityRestore({ phase: 'error', message: err instanceof Error ? err.message : 'Could not send a verification code.' });
725
+ }
726
+ }
727
+
728
+ /** Submit the code: POST `/internal/identity/verify-otp`, then resolve on success. */
729
+ async verifyIdentityOtp(code: string): Promise<void> {
730
+ const state = this.identityRestore;
731
+ const trimmed = code.trim();
732
+ if (!trimmed || state.phase !== 'awaiting_code') return;
733
+ const { email, channel } = state;
734
+ if (!this.sessionId) {
735
+ this.setIdentityRestore({ phase: 'error', message: 'No active session to verify against.' });
736
+ return;
737
+ }
738
+ this.setIdentityRestore({ phase: 'verifying', email, channel });
739
+ try {
740
+ const json = await this.postInternal('/internal/identity/verify-otp', { sessionId: this.sessionId, email, code: trimmed });
741
+ if (json.event === 'otp_verified') {
742
+ // Bind the OTP proof to the session it was verified against, so it
743
+ // can't leak onto a different visitor's session on a shared browser.
744
+ this.store.getState().setVerifiedEmail(email, this.sessionId);
745
+ await this.resolveIdentity(email);
746
+ } else if (json.event === 'otp_invalid') {
747
+ const remaining = typeof json.attemptsRemaining === 'number' ? json.attemptsRemaining : undefined;
748
+ this.setIdentityRestore({ phase: 'awaiting_code', email, channel, error: 'That code was incorrect.', attemptsRemaining: remaining });
749
+ } else {
750
+ this.setIdentityRestore({ phase: 'error', message: 'Verification failed.' });
751
+ }
752
+ } catch (err) {
753
+ this.setIdentityRestore({ phase: 'error', message: err instanceof Error ? err.message : 'Verification failed.' });
754
+ }
755
+ }
756
+
757
+ /** Resolve the verified identity → restorable conversations via POST `/internal/identity/resolve`. */
758
+ private async resolveIdentity(email: string): Promise<void> {
759
+ if (!this.sessionId) return;
760
+ this.setIdentityRestore({ phase: 'resolving', email });
761
+ try {
762
+ const json = await this.postInternal('/internal/identity/resolve', { sessionId: this.sessionId, email });
763
+ if (json.resolved !== true) {
764
+ this.setIdentityRestore({ phase: 'resolved', email, conversations: [] });
765
+ return;
766
+ }
767
+ const raw = json.conversations;
768
+ const conversations: RestorableConversation[] = Array.isArray(raw)
769
+ ? raw
770
+ .map((c): RestorableConversation | null => {
771
+ if (!c || typeof c !== 'object') return null;
772
+ const o = c as Record<string, unknown>;
773
+ const conversationId = typeof o.conversationId === 'string' ? o.conversationId : '';
774
+ const sessionId = typeof o.sessionId === 'string' ? o.sessionId : '';
775
+ if (!sessionId) return null;
776
+ return {
777
+ conversationId,
778
+ sessionId,
779
+ lastActivityAt: typeof o.lastActivityAt === 'string' ? o.lastActivityAt : undefined,
780
+ preview: typeof o.preview === 'string' ? o.preview : undefined,
781
+ };
782
+ })
783
+ .filter((c): c is RestorableConversation => c !== null)
784
+ : [];
785
+ this.setIdentityRestore({ phase: 'resolved', email, conversations });
786
+ } catch (err) {
787
+ this.setIdentityRestore({ phase: 'error', message: err instanceof Error ? err.message : 'Could not load your chats.' });
788
+ }
789
+ }
790
+
791
+ /**
792
+ * Replay a chosen restorable conversation: point the live session at its
793
+ * sessionId, hydrate its transcript (get_session + get_messages), and persist
794
+ * the new pointer so the next `sendMessage` continues it.
795
+ */
796
+ async restoreConversation(sessionId: string): Promise<void> {
797
+ if (!this.client) await this.ensureClient();
798
+ // Capture the OTP proof bound to the CURRENT live session BEFORE tryResume
799
+ // repoints this.sessionId to the restored one. The visitor proved ownership
800
+ // of this email in this very flow, so the proof legitimately follows the
801
+ // conversation they chose to restore.
802
+ const proven = this.sessionId ? this.verifiedEmailForSession(this.sessionId) : null;
803
+ const resumed = await this.tryResume(sessionId);
804
+ if (resumed) {
805
+ this.store.getState().setSessionId(sessionId);
806
+ // Rebind the proof to the restored session (keeps verifiedEmail
807
+ // session-scoped, just now to the session it's actually used on). Only a
808
+ // proof from the just-verified session follows — never an unrelated stale one.
809
+ if (proven) this.store.getState().setVerifiedEmail(proven, sessionId);
810
+ this.setIdentityRestore({ phase: 'idle' });
811
+ this.setStatus('ready');
812
+ } else {
813
+ this.setIdentityRestore({ phase: 'error', message: 'That conversation is no longer available.' });
814
+ }
815
+ }
816
+
817
+ /** Dismiss the cross-device restore panel. */
818
+ cancelIdentityRestore(): void {
819
+ this.setIdentityRestore({ phase: 'idle' });
820
+ }
821
+
312
822
  /** Tear down the underlying client. */
313
823
  disconnect(): void {
314
824
  this.client?.disconnect('widget closed');
315
825
  this.client = null;
316
826
  this.sessionId = null;
317
827
  this.activeRequestId = null;
828
+ // A full teardown ends the controller lifecycle: a subsequent connect() is a
829
+ // genuine re-open and may resume again, so re-arm the resume probe.
830
+ this.resumeAttempted = false;
318
831
  this.setInterrupt(null);
319
832
  this.setStatus('closed');
320
833
  }