@smooai/chat-widget 0.5.2 → 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.
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Browser fingerprint — a stable, privacy-light identifier used by the
3
+ * smooth-operator server to correlate an anonymous visitor's sessions across
4
+ * page loads (and, server-side, to match an anonymous fingerprint to a known
5
+ * CRM contact). It rides every `create_conversation_session` as
6
+ * `browserFingerprint` (see ADR-048).
7
+ *
8
+ * ## Why not ThumbmarkJS
9
+ *
10
+ * The ADR floats ThumbmarkJS as the reference implementation. For an *embed*
11
+ * that is injected onto arbitrary host pages, ThumbmarkJS is too heavy: its
12
+ * full build pulls in extensive device-detection tables and async
13
+ * component collection, adding tens of kilobytes (and async surface) to a
14
+ * bundle whose whole selling point is staying out of the host page's
15
+ * LCP/TBT budget. The fingerprint here is a few hundred bytes.
16
+ *
17
+ * ## What this is (and the tradeoff)
18
+ *
19
+ * The correlation that actually matters — "is this the same browser as last
20
+ * time?" — is carried by a **persisted random UUID** (the `browserFingerprint`
21
+ * field in the persisted store). That UUID is generated once and reused for
22
+ * the life of the localStorage entry, so same-browser resume is exact and
23
+ * deterministic. The signal hash below is a best-effort entropy *supplement*
24
+ * mixed into that UUID's derivation seed on first generation — it gives the
25
+ * server-side resolver a soft signal for the (rare) cross-storage case where
26
+ * the UUID was cleared but the device is unchanged. It is intentionally NOT a
27
+ * high-entropy device fingerprint: no canvas/WebGL/audio probes, no font
28
+ * enumeration, nothing that reads as invasive tracking. The tradeoff is weaker
29
+ * cross-storage matching in exchange for a tiny, transparent, no-network,
30
+ * XSS-safe implementation. The server's resolver (SMOODEV-2129d) is the source
31
+ * of truth for any fuzzy matching; the client just supplies a stable token.
32
+ */
33
+
34
+ /** Collect a small set of stable, non-invasive browser signals. */
35
+ function collectSignals(): string {
36
+ const parts: string[] = [];
37
+ try {
38
+ const nav = typeof navigator !== 'undefined' ? navigator : undefined;
39
+ if (nav) {
40
+ parts.push(`ua:${nav.userAgent ?? ''}`);
41
+ parts.push(`lang:${nav.language ?? ''}`);
42
+ parts.push(`langs:${Array.isArray(nav.languages) ? nav.languages.join(',') : ''}`);
43
+ // `platform` is deprecated but still widely present and stable.
44
+ parts.push(`plat:${(nav as Navigator & { platform?: string }).platform ?? ''}`);
45
+ parts.push(`hc:${(nav as Navigator & { hardwareConcurrency?: number }).hardwareConcurrency ?? ''}`);
46
+ parts.push(`dm:${(nav as Navigator & { deviceMemory?: number }).deviceMemory ?? ''}`);
47
+ }
48
+ if (typeof screen !== 'undefined') {
49
+ parts.push(`scr:${screen.width}x${screen.height}x${screen.colorDepth}`);
50
+ }
51
+ if (typeof Intl !== 'undefined') {
52
+ try {
53
+ parts.push(`tz:${Intl.DateTimeFormat().resolvedOptions().timeZone ?? ''}`);
54
+ } catch {
55
+ /* resolvedOptions can throw in locked-down environments */
56
+ }
57
+ }
58
+ parts.push(`tzo:${new Date().getTimezoneOffset()}`);
59
+ } catch {
60
+ /* a hostile/locked-down navigator must never break widget boot */
61
+ }
62
+ return parts.join('|');
63
+ }
64
+
65
+ /**
66
+ * A small, fast, non-cryptographic string hash (FNV-1a, 32-bit) rendered as
67
+ * an unsigned hex string. Stable across runs for the same input. Not used for
68
+ * any security decision — only to derive a deterministic seed from the signal
69
+ * string above.
70
+ */
71
+ function fnv1a(input: string): string {
72
+ let h = 0x811c9dc5;
73
+ for (let i = 0; i < input.length; i++) {
74
+ h ^= input.charCodeAt(i);
75
+ // 32-bit FNV prime multiply via shifts to stay in int range.
76
+ h = Math.imul(h, 0x01000193);
77
+ }
78
+ // >>> 0 → unsigned; pad to 8 hex chars.
79
+ return (h >>> 0).toString(16).padStart(8, '0');
80
+ }
81
+
82
+ /** Generate a UUID, preferring `crypto.randomUUID`, falling back to a v4-shaped string. */
83
+ function generateUuid(): string {
84
+ try {
85
+ if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
86
+ return crypto.randomUUID();
87
+ }
88
+ } catch {
89
+ /* fall through to the manual generator */
90
+ }
91
+ // RFC 4122 v4 shape from Math.random — only reached when crypto.randomUUID
92
+ // is unavailable (very old engines). Sufficient for a correlation token.
93
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
94
+ const r = (Math.random() * 16) | 0;
95
+ const v = c === 'x' ? r : (r & 0x3) | 0x8;
96
+ return v.toString(16);
97
+ });
98
+ }
99
+
100
+ /**
101
+ * Compute a fresh fingerprint token: a stable random UUID, suffixed with the
102
+ * FNV hash of the device signals so the server can recover a soft device
103
+ * signal even if it only has the token. Called ONCE per browser (the result is
104
+ * persisted and reused) — see {@link getOrCreateFingerprint}.
105
+ */
106
+ export function computeFingerprint(): string {
107
+ const signalHash = fnv1a(collectSignals());
108
+ return `${generateUuid()}.${signalHash}`;
109
+ }
110
+
111
+ /**
112
+ * Return the cached fingerprint if one was already computed for this browser,
113
+ * otherwise compute + persist a new one via the provided accessors. The
114
+ * persistence layer owns storage; this function owns the "compute once" policy.
115
+ */
116
+ export function getOrCreateFingerprint(get: () => string | null, set: (fp: string) => void): string {
117
+ const existing = get();
118
+ if (existing) return existing;
119
+ const fp = computeFingerprint();
120
+ set(fp);
121
+ return fp;
122
+ }
package/src/index.ts CHANGED
@@ -33,9 +33,23 @@ export type { ChatWidgetConfig, ChatWidgetMode, ChatWidgetTheme } from './config
33
33
  export { initChatWidgetLoader } from './loader-core.js';
34
34
  export {
35
35
  ConversationController,
36
+ httpBaseFromWsEndpoint,
36
37
  type ChatMessage,
37
38
  type Citation,
38
39
  type ConnectionStatus,
39
40
  type ConversationEvents,
41
+ type IdentityRestore,
42
+ type RestorableConversation,
40
43
  type Role,
44
+ type UserInfo,
41
45
  } from './conversation.js';
46
+ export {
47
+ createWidgetStore,
48
+ PERSIST_VERSION,
49
+ storageKey,
50
+ type ConsentState,
51
+ type IdentityState,
52
+ type PersistedWidgetState,
53
+ type WidgetStore,
54
+ } from './persistence.js';
55
+ export { computeFingerprint, getOrCreateFingerprint } from './fingerprint.js';
@@ -0,0 +1,368 @@
1
+ /**
2
+ * A tiny, safe-by-default Markdown → HTML renderer for the chat widget.
3
+ *
4
+ * ## Why a hand-rolled renderer (and not markdown-it / snarkdown)?
5
+ *
6
+ * The widget renders **untrusted** text in two places: the assistant's reply
7
+ * (LLM output, which can echo attacker-supplied content) and citation snippets
8
+ * (raw scraped page chunks). Today both are written via `textContent`, so
9
+ * `**bold**`, numbered lists, and `[links](url)` show up literally. We want
10
+ * them rendered — without re-opening the XSS hole that `textContent` was
11
+ * guarding against.
12
+ *
13
+ * markdown-it with `html:false` is safe-by-default but ships ~30 kB min into
14
+ * what is an embeddable **global** bundle, where every kilobyte is on the host
15
+ * page's critical path. snarkdown is ~1 kB but emits raw HTML, so it would
16
+ * require bolting on a separate sanitizer. Instead, this renderer is
17
+ * **safe-by-construction**:
18
+ *
19
+ * 1. It is a *tokenizer*, not an HTML passthrough. It only ever emits a
20
+ * fixed allowlist of tags (`p`, `br`, `strong`, `em`, `ul`/`ol`/`li`,
21
+ * `code`/`pre`, `a`, `blockquote`). There is no code path that copies a
22
+ * tag out of the input — a literal `<script>` in the input is treated as
23
+ * plain text.
24
+ * 2. **Every** text run is HTML-escaped via {@link escapeHtml} before it
25
+ * reaches the output. Raw `<`, `>`, `&`, `"`, `'` can never become markup.
26
+ * 3. **Images are dropped entirely** — `![alt](src)` renders as its alt text,
27
+ * no `<img>` is ever produced (a scraped tracking pixel must not load).
28
+ * 4. **Links** are gated through {@link safeHttpUrl}: only absolute `http(s)`
29
+ * URLs become anchors (with `target="_blank"` + a hardened `rel`);
30
+ * `javascript:`/`data:`/relative/etc. fall back to plain (escaped) text.
31
+ * 5. **Headings** (`#`..`######`) are *downgraded* to bold lines — a full
32
+ * `<h1>` is far too large inside a chat bubble or citation card.
33
+ *
34
+ * The output is a string of HTML that is only ever assigned to `innerHTML` of
35
+ * an element the caller controls; because of (1)–(4) it can only contain the
36
+ * allowlisted, attribute-sanitized tags.
37
+ *
38
+ * Supported subset (deliberately small):
39
+ * - Paragraphs (blank-line separated) and hard/soft line breaks
40
+ * - `**bold**` / `__bold__`, `*italic*` / `_italic_`
41
+ * - `` `inline code` `` and fenced ``` ```code blocks``` ```
42
+ * - `- ` / `* ` / `+ ` unordered lists, `1.` ordered lists
43
+ * - `> ` blockquotes
44
+ * - `[text](http(s)://url)` links (images dropped to alt text)
45
+ * - `#`..`######` headings → bold line
46
+ */
47
+
48
+ /** Escape the five HTML-significant characters so a text run can never be markup. */
49
+ export function escapeHtml(value: string): string {
50
+ return value.replace(/[&<>"']/g, (c) => {
51
+ switch (c) {
52
+ case '&':
53
+ return '&amp;';
54
+ case '<':
55
+ return '&lt;';
56
+ case '>':
57
+ return '&gt;';
58
+ case '"':
59
+ return '&quot;';
60
+ default:
61
+ return '&#39;';
62
+ }
63
+ });
64
+ }
65
+
66
+ /**
67
+ * Return `url` only if it is a valid absolute `http(s)` URL, else `null`.
68
+ *
69
+ * SECURITY: link targets here originate from untrusted content (LLM output /
70
+ * scraped citation chunks). Allowing an arbitrary string as an `href` permits
71
+ * `javascript:`/`data:`/`vbscript:` URLs that execute on click — a stored-XSS
72
+ * vector. Only absolute http(s) links are rendered as anchors; anything else
73
+ * falls back to plain text upstream.
74
+ */
75
+ export function safeHttpUrl(url: string | undefined | null): string | null {
76
+ if (!url) return null;
77
+ try {
78
+ const parsed = new URL(url);
79
+ return parsed.protocol === 'http:' || parsed.protocol === 'https:' ? parsed.href : null;
80
+ } catch {
81
+ return null;
82
+ }
83
+ }
84
+
85
+ // ───────────────────────────── Inline rendering ─────────────────────────────
86
+
87
+ /**
88
+ * Render the inline span grammar of a single line/segment to safe HTML.
89
+ *
90
+ * Order matters: code spans are extracted first (their contents are *not*
91
+ * further parsed), then images are stripped to alt text, then links, then
92
+ * emphasis. Every literal text run is escaped on the way out.
93
+ */
94
+ function renderInline(input: string): string {
95
+ let out = '';
96
+ let i = 0;
97
+ const n = input.length;
98
+
99
+ // Accumulate escaped literal text, flushing on each recognized token.
100
+ let buf = '';
101
+ const flush = () => {
102
+ if (buf) {
103
+ out += escapeHtml(buf);
104
+ buf = '';
105
+ }
106
+ };
107
+
108
+ while (i < n) {
109
+ const ch = input[i]!;
110
+
111
+ // Inline code: `...` — contents are literal (escaped), no nested parsing.
112
+ if (ch === '`') {
113
+ const end = input.indexOf('`', i + 1);
114
+ if (end > i) {
115
+ flush();
116
+ out += `<code>${escapeHtml(input.slice(i + 1, end))}</code>`;
117
+ i = end + 1;
118
+ continue;
119
+ }
120
+ }
121
+
122
+ // Image: ![alt](src) — DROPPED. Emit only the (escaped) alt text; never
123
+ // produce an <img> (a scraped tracking pixel must not load).
124
+ if (ch === '!' && input[i + 1] === '[') {
125
+ const m = imageAt(input, i);
126
+ if (m) {
127
+ flush();
128
+ out += renderInline(m.alt); // alt may itself contain emphasis
129
+ i = m.end;
130
+ continue;
131
+ }
132
+ }
133
+
134
+ // Link: [text](href)
135
+ if (ch === '[') {
136
+ const m = linkAt(input, i);
137
+ if (m) {
138
+ flush();
139
+ const safe = safeHttpUrl(m.href);
140
+ const inner = renderInline(m.text);
141
+ if (safe) {
142
+ out += `<a href="${escapeHtml(safe)}" target="_blank" rel="noopener noreferrer nofollow">${inner}</a>`;
143
+ } else {
144
+ // Unsafe scheme → render as plain (already-escaped) text, no anchor.
145
+ out += inner;
146
+ }
147
+ i = m.end;
148
+ continue;
149
+ }
150
+ }
151
+
152
+ // Bold: **...** or __...__
153
+ if ((ch === '*' && input[i + 1] === '*') || (ch === '_' && input[i + 1] === '_')) {
154
+ const marker = ch + ch;
155
+ const end = input.indexOf(marker, i + 2);
156
+ if (end > i + 1) {
157
+ flush();
158
+ out += `<strong>${renderInline(input.slice(i + 2, end))}</strong>`;
159
+ i = end + 2;
160
+ continue;
161
+ }
162
+ }
163
+
164
+ // Italic: *...* or _..._ (single marker, non-empty, not touching the other marker)
165
+ if (ch === '*' || ch === '_') {
166
+ const end = input.indexOf(ch, i + 1);
167
+ if (end > i + 1 && input[i + 1] !== ch) {
168
+ flush();
169
+ out += `<em>${renderInline(input.slice(i + 1, end))}</em>`;
170
+ i = end + 1;
171
+ continue;
172
+ }
173
+ }
174
+
175
+ buf += ch;
176
+ i++;
177
+ }
178
+
179
+ flush();
180
+ return out;
181
+ }
182
+
183
+ /** Parse a `[text](href)` link starting at `start` (`input[start] === '['`). */
184
+ function linkAt(input: string, start: number): { text: string; href: string; end: number } | null {
185
+ const close = matchBracket(input, start);
186
+ if (close < 0 || input[close + 1] !== '(') return null;
187
+ const paren = input.indexOf(')', close + 2);
188
+ if (paren < 0) return null;
189
+ const text = input.slice(start + 1, close);
190
+ // href is the first whitespace-delimited token inside (...) — ignore any
191
+ // markdown "title" portion; we never render titles.
192
+ const href = input.slice(close + 2, paren).trim().split(/\s+/)[0] ?? '';
193
+ return { text, href, end: paren + 1 };
194
+ }
195
+
196
+ /** Parse a `![alt](src)` image starting at `start` (`input[start] === '!'`). */
197
+ function imageAt(input: string, start: number): { alt: string; end: number } | null {
198
+ const link = linkAt(input, start + 1);
199
+ if (!link) return null;
200
+ return { alt: link.text, end: link.end };
201
+ }
202
+
203
+ /** Find the matching `]` for a `[` at `open`, honoring one level of nesting. */
204
+ function matchBracket(input: string, open: number): number {
205
+ let depth = 0;
206
+ for (let i = open; i < input.length; i++) {
207
+ const c = input[i];
208
+ if (c === '[') depth++;
209
+ else if (c === ']') {
210
+ depth--;
211
+ if (depth === 0) return i;
212
+ }
213
+ }
214
+ return -1;
215
+ }
216
+
217
+ // ───────────────────────────── Block rendering ──────────────────────────────
218
+
219
+ const UL_RE = /^\s*[-*+]\s+(.*)$/;
220
+ const OL_RE = /^\s*\d+[.)]\s+(.*)$/;
221
+ const HEADING_RE = /^\s{0,3}(#{1,6})\s+(.*)$/;
222
+ const QUOTE_RE = /^\s*>\s?(.*)$/;
223
+ const FENCE_RE = /^\s*(`{3,}|~{3,})\s*(.*)$/;
224
+
225
+ /**
226
+ * Render a full Markdown string to safe HTML.
227
+ *
228
+ * @returns a string containing only the allowlisted tags described in the
229
+ * module doc. Safe to assign to `innerHTML` of a caller-owned element.
230
+ */
231
+ export function renderMarkdown(src: string): string {
232
+ const lines = src.replace(/\r\n?/g, '\n').split('\n');
233
+ const out: string[] = [];
234
+
235
+ let i = 0;
236
+ while (i < lines.length) {
237
+ const line = lines[i]!;
238
+
239
+ // Fenced code block: ```lang ... ```
240
+ const fence = FENCE_RE.exec(line);
241
+ if (fence) {
242
+ const marker = fence[1]!;
243
+ const body: string[] = [];
244
+ i++;
245
+ while (i < lines.length && !lines[i]!.trimStart().startsWith(marker)) {
246
+ body.push(lines[i]!);
247
+ i++;
248
+ }
249
+ if (i < lines.length) i++; // consume closing fence
250
+ out.push(`<pre><code>${escapeHtml(body.join('\n'))}</code></pre>`);
251
+ continue;
252
+ }
253
+
254
+ // Blank line → paragraph boundary.
255
+ if (line.trim() === '') {
256
+ i++;
257
+ continue;
258
+ }
259
+
260
+ // Heading → downgraded to a bold line (an <h1> is too big in a bubble).
261
+ const heading = HEADING_RE.exec(line);
262
+ if (heading) {
263
+ out.push(`<p><strong>${renderInline(heading[2]!)}</strong></p>`);
264
+ i++;
265
+ continue;
266
+ }
267
+
268
+ // Unordered / ordered list — consume the contiguous run.
269
+ if (UL_RE.test(line) || OL_RE.test(line)) {
270
+ const ordered = OL_RE.test(line) && !UL_RE.test(line);
271
+ const re = ordered ? OL_RE : UL_RE;
272
+ const items: string[] = [];
273
+ while (i < lines.length) {
274
+ const m = re.exec(lines[i]!);
275
+ if (!m) break;
276
+ items.push(`<li>${renderInline(m[1]!)}</li>`);
277
+ i++;
278
+ }
279
+ out.push(`<${ordered ? 'ol' : 'ul'}>${items.join('')}</${ordered ? 'ol' : 'ul'}>`);
280
+ continue;
281
+ }
282
+
283
+ // Blockquote — consume the contiguous run.
284
+ if (QUOTE_RE.test(line)) {
285
+ const quoted: string[] = [];
286
+ while (i < lines.length) {
287
+ const m = QUOTE_RE.exec(lines[i]!);
288
+ if (!m) break;
289
+ quoted.push(m[1]!);
290
+ i++;
291
+ }
292
+ out.push(`<blockquote>${renderInline(quoted.join('\n')).replace(/\n/g, '<br>')}</blockquote>`);
293
+ continue;
294
+ }
295
+
296
+ // Paragraph — gather consecutive non-blank, non-block lines; soft breaks → <br>.
297
+ const para: string[] = [];
298
+ while (i < lines.length) {
299
+ const l = lines[i]!;
300
+ if (
301
+ l.trim() === '' ||
302
+ FENCE_RE.test(l) ||
303
+ HEADING_RE.test(l) ||
304
+ UL_RE.test(l) ||
305
+ OL_RE.test(l) ||
306
+ QUOTE_RE.test(l)
307
+ ) {
308
+ break;
309
+ }
310
+ para.push(l);
311
+ i++;
312
+ }
313
+ out.push(`<p>${renderInline(para.join('\n')).replace(/\n/g, '<br>')}</p>`);
314
+ }
315
+
316
+ return out.join('');
317
+ }
318
+
319
+ // ───────────────────────── Citation-snippet cleanup ─────────────────────────
320
+
321
+ const SNIPPET_MAX = 260;
322
+
323
+ /**
324
+ * Clean a raw scraped citation snippet into a short, readable excerpt.
325
+ *
326
+ * Scraped chunks frequently begin with page boilerplate — a logo image wrapped
327
+ * in a link, standalone nav, repeated whitespace — e.g.
328
+ * `[![Logo](…)](…) # Our Work We build…`. The source itself is already linked
329
+ * from the citation card, so the snippet only needs to be a clean teaser.
330
+ *
331
+ * Steps:
332
+ * 1. Strip a leading image / logo-link (`[![…](…)](…)` or `![…](…)`).
333
+ * 2. Drop a leading standalone heading marker (`#`/`##`).
334
+ * 3. Collapse all runs of whitespace to single spaces.
335
+ * 4. Truncate to ~{@link SNIPPET_MAX} chars at a word boundary, adding `…`.
336
+ *
337
+ * The result is still rendered through {@link renderMarkdown} downstream, so any
338
+ * remaining inline markup (bold/links) stays safe.
339
+ */
340
+ export function cleanCitationSnippet(raw: string): string {
341
+ let s = raw ?? '';
342
+
343
+ // Repeatedly peel leading boilerplate tokens.
344
+ let changed = true;
345
+ while (changed) {
346
+ changed = false;
347
+ const before = s;
348
+ // Leading linked image: [![alt](imgsrc)](href)
349
+ s = s.replace(/^\s*\[!\[[^\]]*\]\([^)]*\)\]\([^)]*\)\s*/, '');
350
+ // Leading bare image: ![alt](src)
351
+ s = s.replace(/^\s*!\[[^\]]*\]\([^)]*\)\s*/, '');
352
+ // Leading heading marker(s): "# ", "## " (keep the heading text)
353
+ s = s.replace(/^\s*#{1,6}\s+/, '');
354
+ if (s !== before) changed = true;
355
+ }
356
+
357
+ // Collapse whitespace.
358
+ s = s.replace(/\s+/g, ' ').trim();
359
+
360
+ // Truncate at a word boundary.
361
+ if (s.length > SNIPPET_MAX) {
362
+ const cut = s.slice(0, SNIPPET_MAX);
363
+ const lastSpace = cut.lastIndexOf(' ');
364
+ s = (lastSpace > SNIPPET_MAX * 0.6 ? cut.slice(0, lastSpace) : cut).trimEnd() + '…';
365
+ }
366
+
367
+ return s;
368
+ }