@mushi-mushi/core 0.2.0 → 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/README.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  Core types, API client, and utilities for the Mushi Mushi SDK.
4
4
 
5
+ > **You almost certainly don't need to install this directly.** Run `npx mushi-mushi` and the wizard will pick the right framework SDK ([`@mushi-mushi/react`](https://npmjs.com/package/@mushi-mushi/react), [`@mushi-mushi/vue`](https://npmjs.com/package/@mushi-mushi/vue), [`@mushi-mushi/svelte`](https://npmjs.com/package/@mushi-mushi/svelte), [`@mushi-mushi/angular`](https://npmjs.com/package/@mushi-mushi/angular), [`@mushi-mushi/react-native`](https://npmjs.com/package/@mushi-mushi/react-native), [`@mushi-mushi/capacitor`](https://npmjs.com/package/@mushi-mushi/capacitor), or [`@mushi-mushi/web`](https://npmjs.com/package/@mushi-mushi/web)) which depends on this package.
6
+
5
7
  ## What's Inside
6
8
 
7
9
  - **Types**: `MushiConfig`, `MushiReport`, `MushiEnvironment`, and all shared interfaces
package/dist/index.cjs CHANGED
@@ -350,6 +350,98 @@ var noopLogger = {
350
350
  }
351
351
  };
352
352
 
353
+ // src/queue-crypto.ts
354
+ var KEY_DB = "mushi-mushi-keyring";
355
+ var KEY_STORE = "keys";
356
+ var KEY_RECORD_ID = "offline-queue/v1";
357
+ var cachedKey = null;
358
+ var cachedKeyPromise = null;
359
+ function hasWebCrypto() {
360
+ return typeof globalThis !== "undefined" && typeof globalThis.crypto !== "undefined" && typeof globalThis.crypto.subtle !== "undefined" && typeof indexedDB !== "undefined";
361
+ }
362
+ function openKeyDb() {
363
+ return new Promise((resolve, reject) => {
364
+ const req = indexedDB.open(KEY_DB, 1);
365
+ req.onupgradeneeded = () => {
366
+ const db = req.result;
367
+ if (!db.objectStoreNames.contains(KEY_STORE)) {
368
+ db.createObjectStore(KEY_STORE);
369
+ }
370
+ };
371
+ req.onsuccess = () => resolve(req.result);
372
+ req.onerror = () => reject(req.error);
373
+ });
374
+ }
375
+ async function loadKey() {
376
+ const db = await openKeyDb();
377
+ return new Promise((resolve, reject) => {
378
+ const tx = db.transaction(KEY_STORE, "readonly");
379
+ const req = tx.objectStore(KEY_STORE).get(KEY_RECORD_ID);
380
+ req.onsuccess = () => resolve(req.result ?? null);
381
+ req.onerror = () => reject(req.error);
382
+ });
383
+ }
384
+ async function storeKey(key) {
385
+ const db = await openKeyDb();
386
+ return new Promise((resolve, reject) => {
387
+ const tx = db.transaction(KEY_STORE, "readwrite");
388
+ tx.objectStore(KEY_STORE).put(key, KEY_RECORD_ID);
389
+ tx.oncomplete = () => resolve();
390
+ tx.onerror = () => reject(tx.error);
391
+ });
392
+ }
393
+ async function getOfflineQueueKey() {
394
+ if (cachedKey) return cachedKey;
395
+ if (cachedKeyPromise) return cachedKeyPromise;
396
+ if (!hasWebCrypto()) {
397
+ throw new Error("Web Crypto + IndexedDB required for offline queue encryption");
398
+ }
399
+ cachedKeyPromise = (async () => {
400
+ const existing = await loadKey();
401
+ if (existing) {
402
+ cachedKey = existing;
403
+ return existing;
404
+ }
405
+ const key = await crypto.subtle.generateKey(
406
+ { name: "AES-GCM", length: 256 },
407
+ false,
408
+ ["encrypt", "decrypt"]
409
+ );
410
+ await storeKey(key);
411
+ cachedKey = key;
412
+ return key;
413
+ })();
414
+ return cachedKeyPromise;
415
+ }
416
+ function bytesToB64(bytes) {
417
+ let s = "";
418
+ for (const b of bytes) s += String.fromCharCode(b);
419
+ return btoa(s);
420
+ }
421
+ function b64ToBytes(s) {
422
+ const bin = atob(s);
423
+ const out = new Uint8Array(new ArrayBuffer(bin.length));
424
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
425
+ return out;
426
+ }
427
+ async function encryptJson(plain) {
428
+ const key = await getOfflineQueueKey();
429
+ const iv = crypto.getRandomValues(new Uint8Array(12));
430
+ const data = new TextEncoder().encode(JSON.stringify(plain));
431
+ const cipher = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, data));
432
+ return { _mme: 1, iv: bytesToB64(iv), ct: bytesToB64(cipher) };
433
+ }
434
+ function isEncryptedPayload(v) {
435
+ return !!v && typeof v === "object" && v._mme === 1 && typeof v.iv === "string" && typeof v.ct === "string";
436
+ }
437
+ async function decryptJson(payload) {
438
+ const key = await getOfflineQueueKey();
439
+ const iv = b64ToBytes(payload.iv);
440
+ const ct = b64ToBytes(payload.ct);
441
+ const plain = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, ct);
442
+ return JSON.parse(new TextDecoder().decode(plain));
443
+ }
444
+
353
445
  // src/queue.ts
354
446
  var queueLog = createLogger({ scope: "mushi:queue", level: "warn" });
355
447
  var DB_NAME = "mushi-mushi";
@@ -359,9 +451,36 @@ var LS_KEY = "mushi_offline_queue";
359
451
  var BATCH_SIZE = 10;
360
452
  var MAX_BACKOFF_MS = 6e4;
361
453
  function createOfflineQueue(config = {}) {
362
- const { enabled = true, maxQueueSize = 50, syncOnReconnect = true } = config;
454
+ const { enabled = true, maxQueueSize = 50, syncOnReconnect = true, encryptAtRest = true } = config;
363
455
  let syncCleanup = null;
364
456
  let backendType = null;
457
+ async function wrapForStorage(report) {
458
+ const queuedAt = (/* @__PURE__ */ new Date()).toISOString();
459
+ if (!encryptAtRest) {
460
+ return { ...report, queuedAt };
461
+ }
462
+ try {
463
+ const payload = await encryptJson(report);
464
+ return { id: report.id, queuedAt, payload };
465
+ } catch (err) {
466
+ queueLog.warn("Offline queue: encryption failed, storing plaintext", { err: String(err) });
467
+ return { ...report, queuedAt };
468
+ }
469
+ }
470
+ async function unwrapForSend(row) {
471
+ if (isEncryptedRecord(row)) {
472
+ try {
473
+ return await decryptJson(row.payload);
474
+ } catch (err) {
475
+ queueLog.warn("Offline queue: decrypt failed, dropping row", { err: String(err), id: row.id });
476
+ return null;
477
+ }
478
+ }
479
+ return row;
480
+ }
481
+ function isEncryptedRecord(row) {
482
+ return !!row.payload && isEncryptedPayload(row.payload);
483
+ }
365
484
  function detectBackend() {
366
485
  if (backendType) return backendType;
367
486
  if (typeof indexedDB !== "undefined") {
@@ -391,9 +510,10 @@ function createOfflineQueue(config = {}) {
391
510
  }
392
511
  async function idbEnqueue(report) {
393
512
  const db = await openDb();
513
+ const row = await wrapForStorage(report);
394
514
  return new Promise((resolve, reject) => {
395
515
  const tx = db.transaction(STORE_NAME, "readwrite");
396
- tx.objectStore(STORE_NAME).put({ ...report, queuedAt: (/* @__PURE__ */ new Date()).toISOString() });
516
+ tx.objectStore(STORE_NAME).put(row);
397
517
  tx.oncomplete = () => resolve();
398
518
  tx.onerror = () => reject(tx.error);
399
519
  });
@@ -442,20 +562,20 @@ function createOfflineQueue(config = {}) {
442
562
  return [];
443
563
  }
444
564
  }
445
- function lsWrite(reports) {
565
+ function lsWrite(rows) {
446
566
  try {
447
- localStorage.setItem(LS_KEY, JSON.stringify(reports));
567
+ localStorage.setItem(LS_KEY, JSON.stringify(rows));
448
568
  } catch {
449
569
  }
450
570
  }
451
- function lsEnqueue(report) {
452
- const reports = lsRead();
453
- reports.push({ ...report, queuedAt: (/* @__PURE__ */ new Date()).toISOString() });
454
- lsWrite(reports);
571
+ async function lsEnqueue(report) {
572
+ const rows = lsRead();
573
+ rows.push(await wrapForStorage(report));
574
+ lsWrite(rows);
455
575
  }
456
576
  function lsDelete(id) {
457
- const reports = lsRead().filter((r) => r.id !== id);
458
- lsWrite(reports);
577
+ const rows = lsRead().filter((r) => r.id !== id);
578
+ lsWrite(rows);
459
579
  }
460
580
  async function enqueue(report) {
461
581
  if (!enabled) return;
@@ -474,7 +594,7 @@ function createOfflineQueue(config = {}) {
474
594
  }
475
595
  }
476
596
  if (backend === "localstorage" || backendType === "localstorage") {
477
- lsEnqueue(report);
597
+ await lsEnqueue(report);
478
598
  return;
479
599
  }
480
600
  }
@@ -486,29 +606,41 @@ function createOfflineQueue(config = {}) {
486
606
  }
487
607
  async function flush(client) {
488
608
  if (!enabled) return { sent: 0, failed: 0 };
489
- let reports;
609
+ let rows;
490
610
  const backend = detectBackend();
491
611
  if (backend === "indexeddb") {
492
612
  try {
493
- reports = await idbGetAll();
613
+ rows = await idbGetAll();
494
614
  } catch {
495
- reports = lsRead();
615
+ rows = lsRead();
496
616
  }
497
617
  } else {
498
- reports = lsRead();
618
+ rows = lsRead();
499
619
  }
500
- const batch = reports.slice(0, BATCH_SIZE);
620
+ const batch = rows.slice(0, BATCH_SIZE);
501
621
  let sent = 0;
502
622
  let failed = 0;
503
623
  for (let i = 0; i < batch.length; i++) {
504
- const report = batch[i];
624
+ const row = batch[i];
625
+ const rowId = row.id;
626
+ const report = await unwrapForSend(row);
627
+ if (!report) {
628
+ try {
629
+ if (backend === "indexeddb") await idbDelete(rowId);
630
+ else lsDelete(rowId);
631
+ } catch {
632
+ lsDelete(rowId);
633
+ }
634
+ failed++;
635
+ continue;
636
+ }
505
637
  const result = await client.submitReport(report);
506
638
  if (result.ok) {
507
639
  try {
508
- if (backend === "indexeddb") await idbDelete(report.id);
509
- else lsDelete(report.id);
640
+ if (backend === "indexeddb") await idbDelete(rowId);
641
+ else lsDelete(rowId);
510
642
  } catch {
511
- lsDelete(report.id);
643
+ lsDelete(rowId);
512
644
  }
513
645
  sent++;
514
646
  } else {
@@ -622,6 +754,54 @@ function generateToken() {
622
754
  return `mushi_${hex}`;
623
755
  }
624
756
 
757
+ // src/fingerprint.ts
758
+ var CACHE_KEY = "mushi_fingerprint_hash";
759
+ function collectInputs() {
760
+ const nav = typeof navigator !== "undefined" ? navigator : void 0;
761
+ const scr = typeof screen !== "undefined" ? screen : void 0;
762
+ const win = typeof window !== "undefined" ? window : void 0;
763
+ return {
764
+ userAgent: nav?.userAgent ?? "unknown",
765
+ platform: nav?.platform ?? "unknown",
766
+ language: nav?.language ?? "en",
767
+ timezone: Intl?.DateTimeFormat?.()?.resolvedOptions?.()?.timeZone ?? "UTC",
768
+ screenWidth: scr?.width ?? 0,
769
+ screenHeight: scr?.height ?? 0,
770
+ pixelRatio: win?.devicePixelRatio ?? 1,
771
+ deviceMemory: nav?.deviceMemory,
772
+ hardwareConcurrency: nav?.hardwareConcurrency
773
+ };
774
+ }
775
+ async function sha256Hex(input) {
776
+ if (typeof crypto !== "undefined" && crypto.subtle) {
777
+ const buf = new TextEncoder().encode(input);
778
+ const digest = await crypto.subtle.digest("SHA-256", buf);
779
+ return Array.from(new Uint8Array(digest)).map((b) => b.toString(16).padStart(2, "0")).join("");
780
+ }
781
+ let hash = 0;
782
+ for (let i = 0; i < input.length; i++) {
783
+ hash = (hash << 5) - hash + input.charCodeAt(i);
784
+ hash |= 0;
785
+ }
786
+ return `fbk_${(hash >>> 0).toString(16).padStart(8, "0")}`;
787
+ }
788
+ async function getDeviceFingerprintHash() {
789
+ if (typeof localStorage !== "undefined") {
790
+ const cached = localStorage.getItem(CACHE_KEY);
791
+ if (cached) return cached;
792
+ }
793
+ const inputs = collectInputs();
794
+ const serialised = JSON.stringify(inputs);
795
+ const hash = await sha256Hex(serialised);
796
+ if (typeof localStorage !== "undefined") {
797
+ try {
798
+ localStorage.setItem(CACHE_KEY, hash);
799
+ } catch {
800
+ }
801
+ }
802
+ return hash;
803
+ }
804
+
625
805
  // src/session.ts
626
806
  var SESSION_KEY = "mushi_session_id";
627
807
  var cachedSessionId = null;
@@ -694,16 +874,33 @@ function createRateLimiter(config = {}) {
694
874
  var ORDERED_PATTERNS = [
695
875
  { key: "ssns", regex: /\b\d{3}-\d{2}-\d{4}\b/g, replacement: "[REDACTED_SSN]" },
696
876
  { key: "creditCards", regex: /\b(?:\d[ -]*){12,18}\d\b/g, replacement: "[REDACTED_CC]" },
877
+ // Vendor secret tokens — mirrors packages/server/.../pii-scrubber.ts exactly.
878
+ { key: "secretTokens", regex: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g, replacement: "[REDACTED_AWS_KEY]" },
879
+ { 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]" },
880
+ { key: "secretTokens", regex: /\b(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{24,}\b/g, replacement: "[REDACTED_STRIPE_KEY]" },
881
+ { key: "secretTokens", regex: /\bpk_(?:live|test)_[A-Za-z0-9]{24,}\b/g, replacement: "[REDACTED_STRIPE_PK]" },
882
+ { key: "secretTokens", regex: /\bxox[abpor]-[A-Za-z0-9-]{10,}\b/g, replacement: "[REDACTED_SLACK_TOKEN]" },
883
+ { key: "secretTokens", regex: /\bghp_[A-Za-z0-9]{36}\b/g, replacement: "[REDACTED_GITHUB_PAT]" },
884
+ { key: "secretTokens", regex: /\bgithub_pat_[A-Za-z0-9_]{80,}\b/g, replacement: "[REDACTED_GITHUB_PAT]" },
885
+ { key: "secretTokens", regex: /\bsk-(?:proj-)?[A-Za-z0-9_-]{20,}\b/g, replacement: "[REDACTED_OPENAI_KEY]" },
886
+ { key: "secretTokens", regex: /\bsk-ant-[A-Za-z0-9_-]{20,}\b/g, replacement: "[REDACTED_ANTHROPIC_KEY]" },
887
+ { key: "secretTokens", regex: /\bAIza[0-9A-Za-z_-]{35}\b/g, replacement: "[REDACTED_GOOGLE_KEY]" },
888
+ { key: "secretTokens", regex: /\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g, replacement: "[REDACTED_JWT]" },
697
889
  { key: "emails", regex: /\b[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}\b/g, replacement: "[REDACTED_EMAIL]" },
698
890
  { key: "phones", regex: /(?:\+\d{1,3}[\s.-])?\(?\d{2,4}\)?[\s.-]\d{3,4}[\s.-]\d{3,4}\b/g, replacement: "[REDACTED_PHONE]" },
699
- { key: "ipAddresses", regex: /\b(?:\d{1,3}\.){3}\d{1,3}\b/g, replacement: "[REDACTED_IP]" }
891
+ { key: "ipAddresses", regex: /\b(?:\d{1,3}\.){3}\d{1,3}\b/g, replacement: "[REDACTED_IP]" },
892
+ { key: "ipv6", regex: /\b(?:[A-Fa-f0-9]{1,4}:){2,7}[A-Fa-f0-9]{0,4}\b/g, replacement: "[REDACTED_IPV6]" }
700
893
  ];
701
894
  var DEFAULT_CONFIG = {
702
895
  emails: true,
703
896
  phones: true,
704
897
  creditCards: true,
705
898
  ssns: true,
706
- ipAddresses: false
899
+ ipAddresses: false,
900
+ // Secret tokens default ON — if they leak into a bug report there's no
901
+ // good reason to ship them to our servers. Cheaper to scrub client-side.
902
+ secretTokens: true,
903
+ ipv6: false
707
904
  };
708
905
  function createPiiScrubber(config = {}) {
709
906
  const merged = { ...DEFAULT_CONFIG, ...config };
@@ -740,6 +937,7 @@ exports.createOfflineQueue = createOfflineQueue;
740
937
  exports.createPiiScrubber = createPiiScrubber;
741
938
  exports.createPreFilter = createPreFilter;
742
939
  exports.createRateLimiter = createRateLimiter;
940
+ exports.getDeviceFingerprintHash = getDeviceFingerprintHash;
743
941
  exports.getReporterToken = getReporterToken;
744
942
  exports.getSessionId = getSessionId;
745
943
  exports.noopLogger = noopLogger;