@sphoro/bugcatch-web-sdk 0.15.0 → 0.16.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sphoro/bugcatch-web-sdk",
3
- "version": "0.15.0",
3
+ "version": "0.16.0",
4
4
  "description": "Drop-in browser SDK — report bugs with console, network and environment context, without the extension.",
5
5
  "type": "module",
6
6
  "main": "dist/bugcatch.es.js",
package/src/index.js CHANGED
@@ -6,7 +6,7 @@ import { mountWidget } from './widget.js';
6
6
  import { uploadAttachment, normalizeMime, REPORT_MIMES } from './capture.js';
7
7
  import {
8
8
  watchUpdates, createUpdateNotifier, fetchMyReports, fetchReport, writeToken, readToken,
9
- clearToken, sendMessage, verifyFix, callKindsForProject,
9
+ clearToken, scopeReports, sendMessage, verifyFix, callKindsForProject,
10
10
  reportByCall, liveCallAvailable, requestLiveCall, giveUpLiveCall,
11
11
  answerIncomingCall, declineIncomingCall,
12
12
  readUnread, rememberUpdate, forgetUpdate, forgetReportUpdates, forgetAllUpdates,
@@ -188,6 +188,9 @@ class BugCatchClient {
188
188
  if (this.config.captureConsole) this.teardown.push(installConsoleCapture(this.consoleBuffer));
189
189
  if (this.config.captureNetwork) this.teardown.push(installNetworkCapture(this.networkBuffer));
190
190
  if (this.config.captureErrors) this.teardown.push(installErrorCapture(this.consoleBuffer));
191
+ // Before anything reads the watch token: it is kept, and asked about, per
192
+ // project. See scopeReports.
193
+ scopeReports(this.config.projectId);
191
194
  if (this.config.widget) this.teardown.push(mountWidget(this));
192
195
 
193
196
  // Only if this browser has actually filed something. A visitor who has
@@ -206,6 +209,7 @@ class BugCatchClient {
206
209
  }
207
210
 
208
211
  this.started = true;
212
+ this.#sayHello();
209
213
  this.debugLog('initialised', {
210
214
  version: SDK_IDENTITY.version, apiBase: this.config.apiBase, projectId: this.config.projectId,
211
215
  user: this.config.user, context: this.context,
@@ -213,6 +217,41 @@ class BugCatchClient {
213
217
  return this;
214
218
  }
215
219
 
220
+ /**
221
+ * Tell the project this site has the SDK — once a day per browser, a few
222
+ * seconds after init so identify() and setContext() have had their chance.
223
+ *
224
+ * What the Web SDK settings page shows under "Installed on": which site,
225
+ * which version, whether identify() is wired. Nothing personal goes: the
226
+ * origin comes from the browser's own Origin header, and the rest is a
227
+ * version string and four booleans. Every failure is silent — this must
228
+ * never cost a page anything but one small request a day.
229
+ */
230
+ #sayHello() {
231
+ if (typeof window === 'undefined' || typeof fetch !== 'function') return;
232
+ const mark = `bugcatch:hello:${this.config.projectId}`;
233
+ const today = new Date().toISOString().slice(0, 10);
234
+ try { if (window.localStorage.getItem(mark) === today) return; } catch { /* storage blocked: say it anyway */ }
235
+ const timer = setTimeout(() => {
236
+ try { window.localStorage.setItem(mark, today); } catch { /* fine */ }
237
+ fetch(`${this.config.apiBase}/ingest/hello`, {
238
+ method: 'POST',
239
+ keepalive: true,
240
+ headers: { 'Content-Type': 'application/json', 'X-BugCatch-Key': this.config.apiKey },
241
+ body: JSON.stringify({
242
+ projectId: this.config.projectId,
243
+ version: SDK_IDENTITY.version,
244
+ identify: Boolean(this.config.user),
245
+ context: Boolean(this.context && Object.keys(this.context).length),
246
+ widget: Boolean(this.config.widget),
247
+ environment: this.config.environment || null,
248
+ release: this.config.release || null,
249
+ }),
250
+ }).catch(() => {});
251
+ }, 5000);
252
+ this.teardown.push(() => clearTimeout(timer));
253
+ }
254
+
216
255
  /**
217
256
  * Open the update stream. Idempotent — init() and a first report both reach
218
257
  * here, and a second connection would double every notification.
@@ -514,16 +553,49 @@ class BugCatchClient {
514
553
  * a project switched sign-in on, and then a signed-in reporter pressing Call
515
554
  * was told to sign in.
516
555
  */
556
+ /**
557
+ * The host's `beforeSend`, applied to anything about to leave the browser.
558
+ *
559
+ * Its own method because it was written into `report()` alone, and a
560
+ * reporter pressing Call files a report by another route — carrying the same
561
+ * console buffer, the same network buffer and the same identify() fields,
562
+ * past a hook whose whole purpose is to strip them. A host that set this to
563
+ * scrub PII was scrubbed on one button and not the other.
564
+ *
565
+ * Returns the payload to send, or false when the host cancelled.
566
+ */
567
+ #screen(payload) {
568
+ if (typeof this.config.beforeSend !== 'function') return payload;
569
+ let result;
570
+ try {
571
+ result = this.config.beforeSend(payload);
572
+ } catch (err) {
573
+ // A throwing hook is not permission to send what it was meant to clean.
574
+ this.warn(`beforeSend threw, so nothing was sent: ${err?.message || err}`);
575
+ return false;
576
+ }
577
+ if (result === false) return false;
578
+ return result && typeof result === 'object' ? result : payload;
579
+ }
580
+
517
581
  async #fileByCall() {
518
582
  const session = readSession(this.config.projectId);
519
583
 
584
+ // The same hook the form honours. Cancelling here cancels the call: there
585
+ // is nothing to ring about once the report has been refused.
586
+ const payload = this.#screen(await this.#payload({}));
587
+ if (payload === false) {
588
+ this.debugLog('call cancelled by beforeSend');
589
+ return { cancelled: true };
590
+ }
591
+
520
592
  let filed;
521
593
  try {
522
594
  filed = await reportByCall({
523
595
  apiBase: this.config.apiBase,
524
596
  apiKey: this.config.apiKey,
525
597
  token: session?.token,
526
- payload: await this.#payload({}),
598
+ payload,
527
599
  });
528
600
  } catch (err) {
529
601
  // A session the server no longer recognises, dropped exactly as
@@ -763,7 +835,12 @@ class BugCatchClient {
763
835
  // Set after the await, not before: a call that never connected is not one
764
836
  // this page is on, and leaving the slot taken would block the retry.
765
837
  this.call = handle;
766
- return Object.assign(handle, { bugId, callId: started.call_id, live: true, filed });
838
+ // Whether the workspace keeps this call. The widget says so before anybody
839
+ // speaks — the team's incoming card has always been told; the person
840
+ // placing the call was not, which is the wrong way round.
841
+ return Object.assign(handle, {
842
+ bugId, callId: started.call_id, live: true, filed, recorded: Boolean(started.recorded),
843
+ });
767
844
  }
768
845
 
769
846
  /**
@@ -1096,11 +1173,9 @@ class BugCatchClient {
1096
1173
  priority: String(priority).toUpperCase(), metadata, media, references: refs, context,
1097
1174
  });
1098
1175
 
1099
- if (typeof this.config.beforeSend === 'function') {
1100
- const result = this.config.beforeSend(payload);
1101
- if (result === false) return { cancelled: true };
1102
- if (result && typeof result === 'object') payload = result;
1103
- }
1176
+ const screened = this.#screen(payload);
1177
+ if (screened === false) return { cancelled: true };
1178
+ payload = screened;
1104
1179
 
1105
1180
  this.debugLog('sending report', {
1106
1181
  title: payload.title, user: payload.metadata?.user, context: payload.metadata?.context,
package/src/references.js CHANGED
@@ -48,6 +48,24 @@ function compile(source) {
48
48
 
49
49
  const tidy = (value) => String(value).replace(/\s+/g, ' ').trim().slice(0, MAX_VALUE_CHARS);
50
50
 
51
+ /**
52
+ * The value as the customer's database stores it — the same rule as
53
+ * normalizeReferenceValue in bugcatch-api/src/settings/reference-extract.ts,
54
+ * driven by the `normalize` the API sends with each built-in field.
55
+ */
56
+ export function normalizeValue(field, value) {
57
+ const how = field && field.normalize;
58
+ if (how === 'phone') {
59
+ const digits = value.replace(/\D/g, '');
60
+ if (digits.length === 10) return digits;
61
+ if (digits.length === 12 && digits.startsWith('91')) return digits.slice(2);
62
+ if (digits.length === 11 && digits.startsWith('0')) return digits.slice(1);
63
+ return value;
64
+ }
65
+ if (how === 'upper') return value.toUpperCase();
66
+ return value;
67
+ }
68
+
51
69
  /** Group 1 where the pattern has one, the whole match otherwise. */
52
70
  function valueOf(match) {
53
71
  const raw = match.length > 1 && match[1] !== undefined ? match[1] : match[0];
@@ -93,10 +111,11 @@ export function matchReferences(fields, texts) {
93
111
  const hit = valueOf(match);
94
112
  if (hit && !overlaps(hit.start, hit.end)) {
95
113
  claimed.push([hit.start, hit.end]);
96
- const dedupe = `${field.key}:${hit.value.toLowerCase()}`;
114
+ const value = normalizeValue(field, hit.value);
115
+ const dedupe = `${field.key}:${value.toLowerCase()}`;
97
116
  if (!seen.has(dedupe)) {
98
117
  seen.add(dedupe);
99
- found.push({ key: field.key, label: field.label, value: hit.value });
118
+ found.push({ key: field.key, label: field.label, value });
100
119
  }
101
120
  }
102
121
  }
@@ -106,6 +125,139 @@ export function matchReferences(fields, texts) {
106
125
  return found;
107
126
  }
108
127
 
128
+ /** Letters the match read outside its value — the label words, roughly. */
129
+ function labelWeight(text, hit) {
130
+ const around = text.slice(hit.start, hit.valueStart) + text.slice(hit.valueEnd, hit.end);
131
+ return (around.match(/[a-z]/gi) || []).length;
132
+ }
133
+
134
+ /**
135
+ * Things in the text that look like an identifier nobody labelled: a UUID, a
136
+ * run of six or more digits, a code mixing letters and digits. Only ever used
137
+ * to pre-fill the question, never to skip it.
138
+ */
139
+ // No lookbehind: older Safari throws on it at parse time, which here would
140
+ // take the whole SDK down rather than one field. Boundaries are checked by hand.
141
+ const LOOSE_ID = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|[A-Za-z0-9][\w-]{5,40}/gi;
142
+ const WORDISH = /[\w-]/;
143
+
144
+ /**
145
+ * What the reporter's text says about which record this is, sorted by how
146
+ * sure we can be.
147
+ *
148
+ * found — each value one field reads best, and the only value for that
149
+ * field. Enough to send without asking.
150
+ * conflicts — a value two fields read equally well (two bare UUID rules), or
151
+ * a field that found two different values. Asking which is the
152
+ * only honest answer; picking the first is a coin toss filed as
153
+ * a fact.
154
+ * loose — ID-shaped strings no rule claimed, with the fields whose value
155
+ * shape they fit. Offered as the answer when we have to ask
156
+ * anyway, so the reporter confirms instead of retyping.
157
+ *
158
+ * "Reads best" is how many label letters the match consumed: `partner user id
159
+ * 4821` is matched by the partner rule and the plain user rule, and the one
160
+ * that read "partner" knows more. Two rules that read the same words tie, and
161
+ * a tie is a question.
162
+ */
163
+ export function readReferences(fields, texts) {
164
+ const scannable = (fields || []).filter((f) => f && f.scan !== false && f.pattern);
165
+ const hits = [];
166
+ const loose = [];
167
+ const blocks = (texts || []).map((t) => (typeof t === 'string' ? t.slice(0, MAX_SCAN_CHARS) : ''));
168
+
169
+ blocks.forEach((text, block) => {
170
+ if (!text) return;
171
+ scannable.forEach((field, order) => {
172
+ const re = compile(field.pattern);
173
+ if (!re) return;
174
+ let match;
175
+ let guard = 0;
176
+ re.lastIndex = 0;
177
+ while ((match = re.exec(text)) !== null) {
178
+ if (++guard > MAX_MATCHES) break;
179
+ if (match[0] === '') { re.lastIndex += 1; continue; }
180
+ const hit = valueOf(match);
181
+ if (!hit) continue;
182
+ const raw = match.length > 1 && match[1] !== undefined ? match[1] : match[0];
183
+ const valueStart = match.index + Math.max(0, match[0].indexOf(raw));
184
+ const h = {
185
+ block, order, key: field.key, label: field.label, value: normalizeValue(field, hit.value),
186
+ start: hit.start, end: hit.end, valueStart, valueEnd: valueStart + raw.length,
187
+ };
188
+ h.weight = labelWeight(text, h);
189
+ hits.push(h);
190
+ }
191
+ });
192
+ });
193
+
194
+ // A hit loses to one that covers the same value and read more label.
195
+ const survivors = hits.filter((h) => !hits.some((o) => o !== h && o.block === h.block
196
+ && o.key !== h.key && o.valueStart <= h.valueStart && o.valueEnd >= h.valueEnd
197
+ && o.weight > h.weight));
198
+
199
+ // Group what survived by value: one value, several fields = which kind is it?
200
+ const byValue = new Map();
201
+ for (const h of survivors) {
202
+ const id = h.value.toLowerCase();
203
+ if (!byValue.has(id)) byValue.set(id, { value: h.value, keys: new Map() });
204
+ const entry = byValue.get(id);
205
+ if (!entry.keys.has(h.key)) entry.keys.set(h.key, { key: h.key, label: h.label, order: h.order });
206
+ }
207
+
208
+ const found = [];
209
+ const conflicts = [];
210
+ const settledKey = new Map(); // key -> values found for it
211
+ for (const entry of byValue.values()) {
212
+ const kinds = [...entry.keys.values()].sort((a, b) => a.order - b.order);
213
+ if (kinds.length > 1) {
214
+ conflicts.push({ value: entry.value, kinds: kinds.map(({ key, label }) => ({ key, label })) });
215
+ continue;
216
+ }
217
+ const [{ key, label }] = kinds;
218
+ if (!settledKey.has(key)) settledKey.set(key, []);
219
+ settledKey.get(key).push({ key, label, value: entry.value });
220
+ }
221
+ for (const list of settledKey.values()) {
222
+ if (list.length === 1) found.push(list[0]);
223
+ else for (const r of list) conflicts.push({ value: r.value, kinds: [{ key: r.key, label: r.label }] });
224
+ }
225
+
226
+ // Loose candidates: ID-shaped, untouched by any rule, and fitting at least
227
+ // one field's value shape. Nothing that is a URL path or an email.
228
+ const seen = new Set(byValue.keys());
229
+ blocks.forEach((text, block) => {
230
+ if (!text) return;
231
+ const taken = hits.filter((h) => h.block === block);
232
+ const re = new RegExp(LOOSE_ID.source, 'gi');
233
+ let m;
234
+ let guard = 0;
235
+ while ((m = re.exec(text)) !== null && ++guard <= MAX_MATCHES) {
236
+ const s = m.index;
237
+ const e = s + m[0].length;
238
+ if (taken.some((h) => s < h.end && e > h.start)) continue;
239
+ const before = text[s - 1] || '';
240
+ const after = text[e] || '';
241
+ if (WORDISH.test(before) || WORDISH.test(after)) continue;
242
+ // Part of a URL, an address or a file name, not a thing someone quoted.
243
+ if (before === '/' || before === '@' || after === '@' || before === '.') continue;
244
+ if (after === '.' && /\w/.test(text[e + 1] || '')) continue;
245
+ if (!/\d/.test(m[0])) continue;
246
+ const value = tidy(m[0]);
247
+ const id = value.toLowerCase();
248
+ if (seen.has(id)) continue;
249
+ const kinds = (fields || [])
250
+ .filter((f) => f && validateReference(f, value))
251
+ .map(({ key, label }) => ({ key, label }));
252
+ if (!kinds.length) continue;
253
+ seen.add(id);
254
+ loose.push({ value, kinds });
255
+ }
256
+ });
257
+
258
+ return { found, conflicts, loose };
259
+ }
260
+
109
261
  /**
110
262
  * Whether what somebody typed into the box is the shape that field asks for.
111
263
  *
@@ -140,7 +292,7 @@ export function validateReference(field, raw) {
140
292
  const atEdge = match.index === 0 || match.index === 1;
141
293
  if (toEnd && atEdge) {
142
294
  const hit = valueOf(match);
143
- if (hit) return hit.value;
295
+ if (hit) return normalizeValue(field, hit.value);
144
296
  }
145
297
  }
146
298
  return null;
package/src/updates.js CHANGED
@@ -38,10 +38,39 @@ const BACKOFF_MS = [2000, 5000, 15000, 30000, 60000];
38
38
  */
39
39
  const STABLE_MS = 5000;
40
40
 
41
- /** Where the watch token lives. Storage can throw in private modes — never fatal. */
41
+ /**
42
+ * The project this page's widget reports to. Set once by init().
43
+ *
44
+ * The token used to live under one key for the whole origin, and the server
45
+ * answered it with everything its reporter had filed across the workspace — so
46
+ * "Your reports" on project A listed project B's bugs, and two projects on one
47
+ * origin overwrote each other's token. It is now kept per project, and sent
48
+ * with the project so the server can narrow tokens minted before that.
49
+ */
50
+ let project = null;
51
+
52
+ export function scopeReports(projectId) {
53
+ project = projectId || null;
54
+ }
55
+
56
+ const tokenKey = () => (project ? `${TOKEN_KEY}:${project}` : TOKEN_KEY);
57
+
58
+ /** `?projectId=` for the watch routes that list or open reports. */
59
+ export function projectQuery() {
60
+ return project ? `?projectId=${encodeURIComponent(project)}` : '';
61
+ }
62
+
63
+ /**
64
+ * Where the watch token lives. Storage can throw in private modes — never fatal.
65
+ *
66
+ * Falls back to the unscoped key an older SDK wrote, so an upgrade does not
67
+ * lose someone's history. That token may belong to another project on this
68
+ * origin; the `projectId` sent with every read is what keeps it to this one.
69
+ */
42
70
  export function readToken() {
43
71
  try {
44
- return window.localStorage?.getItem(TOKEN_KEY) || null;
72
+ const store = window.localStorage;
73
+ return store?.getItem(tokenKey()) || store?.getItem(TOKEN_KEY) || null;
45
74
  } catch {
46
75
  return null;
47
76
  }
@@ -50,12 +79,13 @@ export function readToken() {
50
79
  export function writeToken(token) {
51
80
  if (!token) return;
52
81
  try {
53
- window.localStorage?.setItem(TOKEN_KEY, token);
82
+ window.localStorage?.setItem(tokenKey(), token);
54
83
  } catch { /* storage unavailable — updates just won't survive a reload */ }
55
84
  }
56
85
 
57
86
  export function clearToken() {
58
87
  try {
88
+ window.localStorage?.removeItem(tokenKey());
59
89
  window.localStorage?.removeItem(TOKEN_KEY);
60
90
  } catch { /* ignore */ }
61
91
  // Anything still waiting to be read goes with it. Unread updates are about
@@ -200,7 +230,7 @@ export async function fetchMyReports({ apiBase }) {
200
230
  const token = readToken();
201
231
  if (!token) return { reporter: null, reports: [] };
202
232
 
203
- const res = await fetch(`${apiBase}/ingest/watch/reports`, {
233
+ const res = await fetch(`${apiBase}/ingest/watch/reports${projectQuery()}`, {
204
234
  headers: { Authorization: `Bearer ${token}` },
205
235
  });
206
236
 
@@ -226,7 +256,7 @@ export async function fetchReport({ apiBase, bugId }) {
226
256
  const token = readToken();
227
257
  if (!token) return null;
228
258
 
229
- const res = await fetch(`${apiBase}/ingest/watch/reports/${encodeURIComponent(bugId)}`, {
259
+ const res = await fetch(`${apiBase}/ingest/watch/reports/${encodeURIComponent(bugId)}${projectQuery()}`, {
230
260
  headers: { Authorization: `Bearer ${token}` },
231
261
  });
232
262
  if (res.status === 401) {
@@ -549,7 +579,7 @@ export function watchUpdates({ apiBase, onUpdate }) {
549
579
  if (!token) throw new Error('nothing filed from this browser yet');
550
580
 
551
581
  controller = new AbortController();
552
- const res = await fetch(`${apiBase}/ingest/watch/stream`, {
582
+ const res = await fetch(`${apiBase}/ingest/watch/stream${projectQuery()}`, {
553
583
  headers: { Authorization: `Bearer ${token}`, Accept: 'text/event-stream' },
554
584
  signal: controller.signal,
555
585
  });