@mushi-mushi/core 0.2.1 → 0.3.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/dist/index.d.cts CHANGED
@@ -95,6 +95,21 @@ interface MushiOfflineConfig {
95
95
  enabled?: boolean;
96
96
  maxQueueSize?: number;
97
97
  syncOnReconnect?: boolean;
98
+ /**
99
+ * Encrypt queued reports at rest (IndexedDB + localStorage) with AES-GCM.
100
+ *
101
+ * Wave S1 / D-16: on a shared device (kiosks, iPads, support-agent
102
+ * laptops) any queued report sits in plaintext until the next online flush.
103
+ * With this flag set we generate a non-extractable AES-GCM key at first use,
104
+ * stash it in a single tightly-scoped IndexedDB record, and wrap every
105
+ * queued report payload under it. The key never leaves the origin's
106
+ * IndexedDB; stealing the DB file on disk still requires the OS-level
107
+ * Web Crypto keystore to decrypt.
108
+ *
109
+ * Defaults to `true`. Set false only if you need to inspect raw queue
110
+ * contents during local dev.
111
+ */
112
+ encryptAtRest?: boolean;
98
113
  }
99
114
  interface MushiRewardsConfig {
100
115
  enabled?: boolean;
@@ -120,7 +135,7 @@ interface MushiReport {
120
135
  sessionId?: string;
121
136
  reporterToken: string;
122
137
  /**
123
- * Wave E §3c — stable per-device hash from `getDeviceFingerprintHash()`.
138
+ * §3c — stable per-device hash from `getDeviceFingerprintHash()`.
124
139
  * Sent so the server can run the cross-account anti-gaming check; falls
125
140
  * back to IP+UA fingerprinting when omitted.
126
141
  */
@@ -215,6 +230,43 @@ interface MushiSDKInstance {
215
230
  open(): void;
216
231
  close(): void;
217
232
  destroy(): void;
233
+ /**
234
+ * Wave G4 — unified `captureEvent` API. Submits a bug report
235
+ * programmatically without opening the widget. Useful for adapters
236
+ * that translate errors from Datadog / Honeycomb / Sentry /
237
+ * Grafana into Mushi reports.
238
+ *
239
+ * Returns the server-assigned report id when the submit succeeds.
240
+ */
241
+ captureEvent(event: MushiCaptureEventInput): Promise<string | null>;
242
+ /**
243
+ * Wave G4 — sugar alias for `setUser()`. Name mirrors the
244
+ * identify/track/capture vocabulary that PostHog/Segment/Mixpanel
245
+ * users already know.
246
+ */
247
+ identify(userId: string, traits?: {
248
+ email?: string;
249
+ name?: string;
250
+ [k: string]: unknown;
251
+ }): void;
252
+ }
253
+ interface MushiCaptureEventInput {
254
+ /** Human-readable summary; becomes `reports.description`. */
255
+ description: string;
256
+ category?: MushiReportCategory;
257
+ severity?: 'critical' | 'high' | 'medium' | 'low';
258
+ component?: string;
259
+ /** Arbitrary tags merged into `reports.metadata.tags`. */
260
+ tags?: Record<string, string | number | boolean>;
261
+ /** Source-of-truth adapter that produced this event (e.g. `'datadog'`). */
262
+ source?: string;
263
+ /** Optional error payload (name/message/stack) captured from the host app. */
264
+ error?: {
265
+ name?: string;
266
+ message?: string;
267
+ stack?: string;
268
+ };
269
+ metadata?: Record<string, unknown>;
218
270
  }
219
271
  interface MushiApiClient {
220
272
  submitReport(report: MushiReport): Promise<MushiApiResponse<{
@@ -248,7 +300,7 @@ declare const DEFAULT_API_ENDPOINT = "https://dxptnwrhwsqckaftyymj.supabase.co/f
248
300
  declare function createApiClient(options: ApiClientOptions): MushiApiClient;
249
301
 
250
302
  /**
251
- * Wave C C7: Data residency region resolution.
303
+ * C7: Data residency region resolution.
252
304
  *
253
305
  * The SDK supports four regional clouds:
254
306
  * - 'us' → United States (default; legacy `dxptnwrhwsqckaftyymj`)
@@ -306,7 +358,7 @@ declare function captureEnvironment(): MushiEnvironment;
306
358
  declare function getReporterToken(): string;
307
359
 
308
360
  /**
309
- * Wave E §3c — stable device fingerprint hash.
361
+ * §3c — stable device fingerprint hash.
310
362
  *
311
363
  * Hashes a deliberately small set of long-lived device characteristics so
312
364
  * the same browser keeps the same hash across sessions, but moving to a
@@ -350,6 +402,19 @@ interface PiiScrubberConfig {
350
402
  creditCards?: boolean;
351
403
  ssns?: boolean;
352
404
  ipAddresses?: boolean;
405
+ /**
406
+ * Scrub vendor-shaped secret tokens (AWS access keys, Stripe keys,
407
+ * Slack/GitHub PATs, OpenAI/Anthropic/Google keys, JWTs).
408
+ *
409
+ * Wave S1 / D-15: SDK parity with the server-side scrubber. The server
410
+ * scrubs these on every LLM invocation; the SDK now scrubs them at
411
+ * capture so they never hit the wire in the first place — important for
412
+ * users who `console.log(stripeKey)` during dev and later ship bug
413
+ * reports with the error text attached.
414
+ */
415
+ secretTokens?: boolean;
416
+ /** IPv6 addresses. Defaults off for the same reason IPv4 does. */
417
+ ipv6?: boolean;
353
418
  }
354
419
  declare function createPiiScrubber(config?: PiiScrubberConfig): {
355
420
  scrub: (text: string) => string;
@@ -423,4 +488,4 @@ declare function createLogger(options: LoggerOptions): Logger;
423
488
  */
424
489
  declare const noopLogger: Logger;
425
490
 
426
- export { type ApiClientOptions, DEFAULT_API_ENDPOINT, type LogEntry, type LogFormat, type LogLevel, type Logger, type LoggerOptions, type MushiApiClient, type MushiApiResponse, type MushiCaptureConfig, type MushiConfig, type MushiConsoleEntry, type MushiCooldownConfig, type MushiEnvironment, type MushiEventHandler, type MushiEventType, type MushiIntegrationsConfig, type MushiNetworkEntry, type MushiOfflineConfig, type MushiOnDeviceClassifier, type MushiOnDeviceClassifierInput, type MushiOnDeviceClassifierResult, type MushiPerformanceMetrics, type MushiPreFilterConfig, type MushiProactiveConfig, type MushiRegion, type MushiReport, type MushiReportBuilder, type MushiReportCategory, type MushiReportStatus, type MushiRewardsConfig, type MushiSDKInstance, type MushiSelectedElement, type MushiSentryConfig, type MushiWidgetConfig, type OfflineQueue, type PiiScrubberConfig, type PreFilterResult, REGION_ENDPOINTS, type RateLimiter, type RateLimiterConfig, captureEnvironment, createApiClient, createLogger, createOfflineQueue, createPiiScrubber, createPreFilter, createRateLimiter, getDeviceFingerprintHash, getReporterToken, getSessionId, noopLogger, resolveRegionEndpoint, scrubPii };
491
+ export { type ApiClientOptions, DEFAULT_API_ENDPOINT, type LogEntry, type LogFormat, type LogLevel, type Logger, type LoggerOptions, type MushiApiClient, type MushiApiResponse, type MushiCaptureConfig, type MushiCaptureEventInput, type MushiConfig, type MushiConsoleEntry, type MushiCooldownConfig, type MushiEnvironment, type MushiEventHandler, type MushiEventType, type MushiIntegrationsConfig, type MushiNetworkEntry, type MushiOfflineConfig, type MushiOnDeviceClassifier, type MushiOnDeviceClassifierInput, type MushiOnDeviceClassifierResult, type MushiPerformanceMetrics, type MushiPreFilterConfig, type MushiProactiveConfig, type MushiRegion, type MushiReport, type MushiReportBuilder, type MushiReportCategory, type MushiReportStatus, type MushiRewardsConfig, type MushiSDKInstance, type MushiSelectedElement, type MushiSentryConfig, type MushiWidgetConfig, type OfflineQueue, type PiiScrubberConfig, type PreFilterResult, REGION_ENDPOINTS, type RateLimiter, type RateLimiterConfig, captureEnvironment, createApiClient, createLogger, createOfflineQueue, createPiiScrubber, createPreFilter, createRateLimiter, getDeviceFingerprintHash, getReporterToken, getSessionId, noopLogger, resolveRegionEndpoint, scrubPii };
package/dist/index.d.ts CHANGED
@@ -95,6 +95,21 @@ interface MushiOfflineConfig {
95
95
  enabled?: boolean;
96
96
  maxQueueSize?: number;
97
97
  syncOnReconnect?: boolean;
98
+ /**
99
+ * Encrypt queued reports at rest (IndexedDB + localStorage) with AES-GCM.
100
+ *
101
+ * Wave S1 / D-16: on a shared device (kiosks, iPads, support-agent
102
+ * laptops) any queued report sits in plaintext until the next online flush.
103
+ * With this flag set we generate a non-extractable AES-GCM key at first use,
104
+ * stash it in a single tightly-scoped IndexedDB record, and wrap every
105
+ * queued report payload under it. The key never leaves the origin's
106
+ * IndexedDB; stealing the DB file on disk still requires the OS-level
107
+ * Web Crypto keystore to decrypt.
108
+ *
109
+ * Defaults to `true`. Set false only if you need to inspect raw queue
110
+ * contents during local dev.
111
+ */
112
+ encryptAtRest?: boolean;
98
113
  }
99
114
  interface MushiRewardsConfig {
100
115
  enabled?: boolean;
@@ -120,7 +135,7 @@ interface MushiReport {
120
135
  sessionId?: string;
121
136
  reporterToken: string;
122
137
  /**
123
- * Wave E §3c — stable per-device hash from `getDeviceFingerprintHash()`.
138
+ * §3c — stable per-device hash from `getDeviceFingerprintHash()`.
124
139
  * Sent so the server can run the cross-account anti-gaming check; falls
125
140
  * back to IP+UA fingerprinting when omitted.
126
141
  */
@@ -215,6 +230,43 @@ interface MushiSDKInstance {
215
230
  open(): void;
216
231
  close(): void;
217
232
  destroy(): void;
233
+ /**
234
+ * Wave G4 — unified `captureEvent` API. Submits a bug report
235
+ * programmatically without opening the widget. Useful for adapters
236
+ * that translate errors from Datadog / Honeycomb / Sentry /
237
+ * Grafana into Mushi reports.
238
+ *
239
+ * Returns the server-assigned report id when the submit succeeds.
240
+ */
241
+ captureEvent(event: MushiCaptureEventInput): Promise<string | null>;
242
+ /**
243
+ * Wave G4 — sugar alias for `setUser()`. Name mirrors the
244
+ * identify/track/capture vocabulary that PostHog/Segment/Mixpanel
245
+ * users already know.
246
+ */
247
+ identify(userId: string, traits?: {
248
+ email?: string;
249
+ name?: string;
250
+ [k: string]: unknown;
251
+ }): void;
252
+ }
253
+ interface MushiCaptureEventInput {
254
+ /** Human-readable summary; becomes `reports.description`. */
255
+ description: string;
256
+ category?: MushiReportCategory;
257
+ severity?: 'critical' | 'high' | 'medium' | 'low';
258
+ component?: string;
259
+ /** Arbitrary tags merged into `reports.metadata.tags`. */
260
+ tags?: Record<string, string | number | boolean>;
261
+ /** Source-of-truth adapter that produced this event (e.g. `'datadog'`). */
262
+ source?: string;
263
+ /** Optional error payload (name/message/stack) captured from the host app. */
264
+ error?: {
265
+ name?: string;
266
+ message?: string;
267
+ stack?: string;
268
+ };
269
+ metadata?: Record<string, unknown>;
218
270
  }
219
271
  interface MushiApiClient {
220
272
  submitReport(report: MushiReport): Promise<MushiApiResponse<{
@@ -248,7 +300,7 @@ declare const DEFAULT_API_ENDPOINT = "https://dxptnwrhwsqckaftyymj.supabase.co/f
248
300
  declare function createApiClient(options: ApiClientOptions): MushiApiClient;
249
301
 
250
302
  /**
251
- * Wave C C7: Data residency region resolution.
303
+ * C7: Data residency region resolution.
252
304
  *
253
305
  * The SDK supports four regional clouds:
254
306
  * - 'us' → United States (default; legacy `dxptnwrhwsqckaftyymj`)
@@ -306,7 +358,7 @@ declare function captureEnvironment(): MushiEnvironment;
306
358
  declare function getReporterToken(): string;
307
359
 
308
360
  /**
309
- * Wave E §3c — stable device fingerprint hash.
361
+ * §3c — stable device fingerprint hash.
310
362
  *
311
363
  * Hashes a deliberately small set of long-lived device characteristics so
312
364
  * the same browser keeps the same hash across sessions, but moving to a
@@ -350,6 +402,19 @@ interface PiiScrubberConfig {
350
402
  creditCards?: boolean;
351
403
  ssns?: boolean;
352
404
  ipAddresses?: boolean;
405
+ /**
406
+ * Scrub vendor-shaped secret tokens (AWS access keys, Stripe keys,
407
+ * Slack/GitHub PATs, OpenAI/Anthropic/Google keys, JWTs).
408
+ *
409
+ * Wave S1 / D-15: SDK parity with the server-side scrubber. The server
410
+ * scrubs these on every LLM invocation; the SDK now scrubs them at
411
+ * capture so they never hit the wire in the first place — important for
412
+ * users who `console.log(stripeKey)` during dev and later ship bug
413
+ * reports with the error text attached.
414
+ */
415
+ secretTokens?: boolean;
416
+ /** IPv6 addresses. Defaults off for the same reason IPv4 does. */
417
+ ipv6?: boolean;
353
418
  }
354
419
  declare function createPiiScrubber(config?: PiiScrubberConfig): {
355
420
  scrub: (text: string) => string;
@@ -423,4 +488,4 @@ declare function createLogger(options: LoggerOptions): Logger;
423
488
  */
424
489
  declare const noopLogger: Logger;
425
490
 
426
- export { type ApiClientOptions, DEFAULT_API_ENDPOINT, type LogEntry, type LogFormat, type LogLevel, type Logger, type LoggerOptions, type MushiApiClient, type MushiApiResponse, type MushiCaptureConfig, type MushiConfig, type MushiConsoleEntry, type MushiCooldownConfig, type MushiEnvironment, type MushiEventHandler, type MushiEventType, type MushiIntegrationsConfig, type MushiNetworkEntry, type MushiOfflineConfig, type MushiOnDeviceClassifier, type MushiOnDeviceClassifierInput, type MushiOnDeviceClassifierResult, type MushiPerformanceMetrics, type MushiPreFilterConfig, type MushiProactiveConfig, type MushiRegion, type MushiReport, type MushiReportBuilder, type MushiReportCategory, type MushiReportStatus, type MushiRewardsConfig, type MushiSDKInstance, type MushiSelectedElement, type MushiSentryConfig, type MushiWidgetConfig, type OfflineQueue, type PiiScrubberConfig, type PreFilterResult, REGION_ENDPOINTS, type RateLimiter, type RateLimiterConfig, captureEnvironment, createApiClient, createLogger, createOfflineQueue, createPiiScrubber, createPreFilter, createRateLimiter, getDeviceFingerprintHash, getReporterToken, getSessionId, noopLogger, resolveRegionEndpoint, scrubPii };
491
+ export { type ApiClientOptions, DEFAULT_API_ENDPOINT, type LogEntry, type LogFormat, type LogLevel, type Logger, type LoggerOptions, type MushiApiClient, type MushiApiResponse, type MushiCaptureConfig, type MushiCaptureEventInput, type MushiConfig, type MushiConsoleEntry, type MushiCooldownConfig, type MushiEnvironment, type MushiEventHandler, type MushiEventType, type MushiIntegrationsConfig, type MushiNetworkEntry, type MushiOfflineConfig, type MushiOnDeviceClassifier, type MushiOnDeviceClassifierInput, type MushiOnDeviceClassifierResult, type MushiPerformanceMetrics, type MushiPreFilterConfig, type MushiProactiveConfig, type MushiRegion, type MushiReport, type MushiReportBuilder, type MushiReportCategory, type MushiReportStatus, type MushiRewardsConfig, type MushiSDKInstance, type MushiSelectedElement, type MushiSentryConfig, type MushiWidgetConfig, type OfflineQueue, type PiiScrubberConfig, type PreFilterResult, REGION_ENDPOINTS, type RateLimiter, type RateLimiterConfig, captureEnvironment, createApiClient, createLogger, createOfflineQueue, createPiiScrubber, createPreFilter, createRateLimiter, getDeviceFingerprintHash, getReporterToken, getSessionId, noopLogger, resolveRegionEndpoint, scrubPii };
package/dist/index.js CHANGED
@@ -348,6 +348,98 @@ var noopLogger = {
348
348
  }
349
349
  };
350
350
 
351
+ // src/queue-crypto.ts
352
+ var KEY_DB = "mushi-mushi-keyring";
353
+ var KEY_STORE = "keys";
354
+ var KEY_RECORD_ID = "offline-queue/v1";
355
+ var cachedKey = null;
356
+ var cachedKeyPromise = null;
357
+ function hasWebCrypto() {
358
+ return typeof globalThis !== "undefined" && typeof globalThis.crypto !== "undefined" && typeof globalThis.crypto.subtle !== "undefined" && typeof indexedDB !== "undefined";
359
+ }
360
+ function openKeyDb() {
361
+ return new Promise((resolve, reject) => {
362
+ const req = indexedDB.open(KEY_DB, 1);
363
+ req.onupgradeneeded = () => {
364
+ const db = req.result;
365
+ if (!db.objectStoreNames.contains(KEY_STORE)) {
366
+ db.createObjectStore(KEY_STORE);
367
+ }
368
+ };
369
+ req.onsuccess = () => resolve(req.result);
370
+ req.onerror = () => reject(req.error);
371
+ });
372
+ }
373
+ async function loadKey() {
374
+ const db = await openKeyDb();
375
+ return new Promise((resolve, reject) => {
376
+ const tx = db.transaction(KEY_STORE, "readonly");
377
+ const req = tx.objectStore(KEY_STORE).get(KEY_RECORD_ID);
378
+ req.onsuccess = () => resolve(req.result ?? null);
379
+ req.onerror = () => reject(req.error);
380
+ });
381
+ }
382
+ async function storeKey(key) {
383
+ const db = await openKeyDb();
384
+ return new Promise((resolve, reject) => {
385
+ const tx = db.transaction(KEY_STORE, "readwrite");
386
+ tx.objectStore(KEY_STORE).put(key, KEY_RECORD_ID);
387
+ tx.oncomplete = () => resolve();
388
+ tx.onerror = () => reject(tx.error);
389
+ });
390
+ }
391
+ async function getOfflineQueueKey() {
392
+ if (cachedKey) return cachedKey;
393
+ if (cachedKeyPromise) return cachedKeyPromise;
394
+ if (!hasWebCrypto()) {
395
+ throw new Error("Web Crypto + IndexedDB required for offline queue encryption");
396
+ }
397
+ cachedKeyPromise = (async () => {
398
+ const existing = await loadKey();
399
+ if (existing) {
400
+ cachedKey = existing;
401
+ return existing;
402
+ }
403
+ const key = await crypto.subtle.generateKey(
404
+ { name: "AES-GCM", length: 256 },
405
+ false,
406
+ ["encrypt", "decrypt"]
407
+ );
408
+ await storeKey(key);
409
+ cachedKey = key;
410
+ return key;
411
+ })();
412
+ return cachedKeyPromise;
413
+ }
414
+ function bytesToB64(bytes) {
415
+ let s = "";
416
+ for (const b of bytes) s += String.fromCharCode(b);
417
+ return btoa(s);
418
+ }
419
+ function b64ToBytes(s) {
420
+ const bin = atob(s);
421
+ const out = new Uint8Array(new ArrayBuffer(bin.length));
422
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
423
+ return out;
424
+ }
425
+ async function encryptJson(plain) {
426
+ const key = await getOfflineQueueKey();
427
+ const iv = crypto.getRandomValues(new Uint8Array(12));
428
+ const data = new TextEncoder().encode(JSON.stringify(plain));
429
+ const cipher = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, data));
430
+ return { _mme: 1, iv: bytesToB64(iv), ct: bytesToB64(cipher) };
431
+ }
432
+ function isEncryptedPayload(v) {
433
+ return !!v && typeof v === "object" && v._mme === 1 && typeof v.iv === "string" && typeof v.ct === "string";
434
+ }
435
+ async function decryptJson(payload) {
436
+ const key = await getOfflineQueueKey();
437
+ const iv = b64ToBytes(payload.iv);
438
+ const ct = b64ToBytes(payload.ct);
439
+ const plain = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, ct);
440
+ return JSON.parse(new TextDecoder().decode(plain));
441
+ }
442
+
351
443
  // src/queue.ts
352
444
  var queueLog = createLogger({ scope: "mushi:queue", level: "warn" });
353
445
  var DB_NAME = "mushi-mushi";
@@ -357,9 +449,36 @@ var LS_KEY = "mushi_offline_queue";
357
449
  var BATCH_SIZE = 10;
358
450
  var MAX_BACKOFF_MS = 6e4;
359
451
  function createOfflineQueue(config = {}) {
360
- const { enabled = true, maxQueueSize = 50, syncOnReconnect = true } = config;
452
+ const { enabled = true, maxQueueSize = 50, syncOnReconnect = true, encryptAtRest = true } = config;
361
453
  let syncCleanup = null;
362
454
  let backendType = null;
455
+ async function wrapForStorage(report) {
456
+ const queuedAt = (/* @__PURE__ */ new Date()).toISOString();
457
+ if (!encryptAtRest) {
458
+ return { ...report, queuedAt };
459
+ }
460
+ try {
461
+ const payload = await encryptJson(report);
462
+ return { id: report.id, queuedAt, payload };
463
+ } catch (err) {
464
+ queueLog.warn("Offline queue: encryption failed, storing plaintext", { err: String(err) });
465
+ return { ...report, queuedAt };
466
+ }
467
+ }
468
+ async function unwrapForSend(row) {
469
+ if (isEncryptedRecord(row)) {
470
+ try {
471
+ return await decryptJson(row.payload);
472
+ } catch (err) {
473
+ queueLog.warn("Offline queue: decrypt failed, dropping row", { err: String(err), id: row.id });
474
+ return null;
475
+ }
476
+ }
477
+ return row;
478
+ }
479
+ function isEncryptedRecord(row) {
480
+ return !!row.payload && isEncryptedPayload(row.payload);
481
+ }
363
482
  function detectBackend() {
364
483
  if (backendType) return backendType;
365
484
  if (typeof indexedDB !== "undefined") {
@@ -389,9 +508,10 @@ function createOfflineQueue(config = {}) {
389
508
  }
390
509
  async function idbEnqueue(report) {
391
510
  const db = await openDb();
511
+ const row = await wrapForStorage(report);
392
512
  return new Promise((resolve, reject) => {
393
513
  const tx = db.transaction(STORE_NAME, "readwrite");
394
- tx.objectStore(STORE_NAME).put({ ...report, queuedAt: (/* @__PURE__ */ new Date()).toISOString() });
514
+ tx.objectStore(STORE_NAME).put(row);
395
515
  tx.oncomplete = () => resolve();
396
516
  tx.onerror = () => reject(tx.error);
397
517
  });
@@ -440,20 +560,20 @@ function createOfflineQueue(config = {}) {
440
560
  return [];
441
561
  }
442
562
  }
443
- function lsWrite(reports) {
563
+ function lsWrite(rows) {
444
564
  try {
445
- localStorage.setItem(LS_KEY, JSON.stringify(reports));
565
+ localStorage.setItem(LS_KEY, JSON.stringify(rows));
446
566
  } catch {
447
567
  }
448
568
  }
449
- function lsEnqueue(report) {
450
- const reports = lsRead();
451
- reports.push({ ...report, queuedAt: (/* @__PURE__ */ new Date()).toISOString() });
452
- lsWrite(reports);
569
+ async function lsEnqueue(report) {
570
+ const rows = lsRead();
571
+ rows.push(await wrapForStorage(report));
572
+ lsWrite(rows);
453
573
  }
454
574
  function lsDelete(id) {
455
- const reports = lsRead().filter((r) => r.id !== id);
456
- lsWrite(reports);
575
+ const rows = lsRead().filter((r) => r.id !== id);
576
+ lsWrite(rows);
457
577
  }
458
578
  async function enqueue(report) {
459
579
  if (!enabled) return;
@@ -472,7 +592,7 @@ function createOfflineQueue(config = {}) {
472
592
  }
473
593
  }
474
594
  if (backend === "localstorage" || backendType === "localstorage") {
475
- lsEnqueue(report);
595
+ await lsEnqueue(report);
476
596
  return;
477
597
  }
478
598
  }
@@ -484,29 +604,41 @@ function createOfflineQueue(config = {}) {
484
604
  }
485
605
  async function flush(client) {
486
606
  if (!enabled) return { sent: 0, failed: 0 };
487
- let reports;
607
+ let rows;
488
608
  const backend = detectBackend();
489
609
  if (backend === "indexeddb") {
490
610
  try {
491
- reports = await idbGetAll();
611
+ rows = await idbGetAll();
492
612
  } catch {
493
- reports = lsRead();
613
+ rows = lsRead();
494
614
  }
495
615
  } else {
496
- reports = lsRead();
616
+ rows = lsRead();
497
617
  }
498
- const batch = reports.slice(0, BATCH_SIZE);
618
+ const batch = rows.slice(0, BATCH_SIZE);
499
619
  let sent = 0;
500
620
  let failed = 0;
501
621
  for (let i = 0; i < batch.length; i++) {
502
- const report = batch[i];
622
+ const row = batch[i];
623
+ const rowId = row.id;
624
+ const report = await unwrapForSend(row);
625
+ if (!report) {
626
+ try {
627
+ if (backend === "indexeddb") await idbDelete(rowId);
628
+ else lsDelete(rowId);
629
+ } catch {
630
+ lsDelete(rowId);
631
+ }
632
+ failed++;
633
+ continue;
634
+ }
503
635
  const result = await client.submitReport(report);
504
636
  if (result.ok) {
505
637
  try {
506
- if (backend === "indexeddb") await idbDelete(report.id);
507
- else lsDelete(report.id);
638
+ if (backend === "indexeddb") await idbDelete(rowId);
639
+ else lsDelete(rowId);
508
640
  } catch {
509
- lsDelete(report.id);
641
+ lsDelete(rowId);
510
642
  }
511
643
  sent++;
512
644
  } else {
@@ -740,16 +872,33 @@ function createRateLimiter(config = {}) {
740
872
  var ORDERED_PATTERNS = [
741
873
  { key: "ssns", regex: /\b\d{3}-\d{2}-\d{4}\b/g, replacement: "[REDACTED_SSN]" },
742
874
  { key: "creditCards", regex: /\b(?:\d[ -]*){12,18}\d\b/g, replacement: "[REDACTED_CC]" },
875
+ // Vendor secret tokens — mirrors packages/server/.../pii-scrubber.ts exactly.
876
+ { key: "secretTokens", regex: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g, replacement: "[REDACTED_AWS_KEY]" },
877
+ { key: "secretTokens", regex: /(?:aws_secret_access_key|secret_access_key)["'\s:=]+[A-Za-z0-9/+=]{40}\b/gi, replacement: "aws_secret_access_key=[REDACTED_AWS_SECRET]" },
878
+ { key: "secretTokens", regex: /\b(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{24,}\b/g, replacement: "[REDACTED_STRIPE_KEY]" },
879
+ { key: "secretTokens", regex: /\bpk_(?:live|test)_[A-Za-z0-9]{24,}\b/g, replacement: "[REDACTED_STRIPE_PK]" },
880
+ { key: "secretTokens", regex: /\bxox[abpor]-[A-Za-z0-9-]{10,}\b/g, replacement: "[REDACTED_SLACK_TOKEN]" },
881
+ { key: "secretTokens", regex: /\bghp_[A-Za-z0-9]{36}\b/g, replacement: "[REDACTED_GITHUB_PAT]" },
882
+ { key: "secretTokens", regex: /\bgithub_pat_[A-Za-z0-9_]{80,}\b/g, replacement: "[REDACTED_GITHUB_PAT]" },
883
+ { key: "secretTokens", regex: /\bsk-(?:proj-)?[A-Za-z0-9_-]{20,}\b/g, replacement: "[REDACTED_OPENAI_KEY]" },
884
+ { key: "secretTokens", regex: /\bsk-ant-[A-Za-z0-9_-]{20,}\b/g, replacement: "[REDACTED_ANTHROPIC_KEY]" },
885
+ { key: "secretTokens", regex: /\bAIza[0-9A-Za-z_-]{35}\b/g, replacement: "[REDACTED_GOOGLE_KEY]" },
886
+ { key: "secretTokens", regex: /\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g, replacement: "[REDACTED_JWT]" },
743
887
  { key: "emails", regex: /\b[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}\b/g, replacement: "[REDACTED_EMAIL]" },
744
888
  { key: "phones", regex: /(?:\+\d{1,3}[\s.-])?\(?\d{2,4}\)?[\s.-]\d{3,4}[\s.-]\d{3,4}\b/g, replacement: "[REDACTED_PHONE]" },
745
- { key: "ipAddresses", regex: /\b(?:\d{1,3}\.){3}\d{1,3}\b/g, replacement: "[REDACTED_IP]" }
889
+ { key: "ipAddresses", regex: /\b(?:\d{1,3}\.){3}\d{1,3}\b/g, replacement: "[REDACTED_IP]" },
890
+ { key: "ipv6", regex: /\b(?:[A-Fa-f0-9]{1,4}:){2,7}[A-Fa-f0-9]{0,4}\b/g, replacement: "[REDACTED_IPV6]" }
746
891
  ];
747
892
  var DEFAULT_CONFIG = {
748
893
  emails: true,
749
894
  phones: true,
750
895
  creditCards: true,
751
896
  ssns: true,
752
- ipAddresses: false
897
+ ipAddresses: false,
898
+ // Secret tokens default ON — if they leak into a bug report there's no
899
+ // good reason to ship them to our servers. Cheaper to scrub client-side.
900
+ secretTokens: true,
901
+ ipv6: false
753
902
  };
754
903
  function createPiiScrubber(config = {}) {
755
904
  const merged = { ...DEFAULT_CONFIG, ...config };