@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/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 {
@@ -620,6 +752,54 @@ function generateToken() {
620
752
  return `mushi_${hex}`;
621
753
  }
622
754
 
755
+ // src/fingerprint.ts
756
+ var CACHE_KEY = "mushi_fingerprint_hash";
757
+ function collectInputs() {
758
+ const nav = typeof navigator !== "undefined" ? navigator : void 0;
759
+ const scr = typeof screen !== "undefined" ? screen : void 0;
760
+ const win = typeof window !== "undefined" ? window : void 0;
761
+ return {
762
+ userAgent: nav?.userAgent ?? "unknown",
763
+ platform: nav?.platform ?? "unknown",
764
+ language: nav?.language ?? "en",
765
+ timezone: Intl?.DateTimeFormat?.()?.resolvedOptions?.()?.timeZone ?? "UTC",
766
+ screenWidth: scr?.width ?? 0,
767
+ screenHeight: scr?.height ?? 0,
768
+ pixelRatio: win?.devicePixelRatio ?? 1,
769
+ deviceMemory: nav?.deviceMemory,
770
+ hardwareConcurrency: nav?.hardwareConcurrency
771
+ };
772
+ }
773
+ async function sha256Hex(input) {
774
+ if (typeof crypto !== "undefined" && crypto.subtle) {
775
+ const buf = new TextEncoder().encode(input);
776
+ const digest = await crypto.subtle.digest("SHA-256", buf);
777
+ return Array.from(new Uint8Array(digest)).map((b) => b.toString(16).padStart(2, "0")).join("");
778
+ }
779
+ let hash = 0;
780
+ for (let i = 0; i < input.length; i++) {
781
+ hash = (hash << 5) - hash + input.charCodeAt(i);
782
+ hash |= 0;
783
+ }
784
+ return `fbk_${(hash >>> 0).toString(16).padStart(8, "0")}`;
785
+ }
786
+ async function getDeviceFingerprintHash() {
787
+ if (typeof localStorage !== "undefined") {
788
+ const cached = localStorage.getItem(CACHE_KEY);
789
+ if (cached) return cached;
790
+ }
791
+ const inputs = collectInputs();
792
+ const serialised = JSON.stringify(inputs);
793
+ const hash = await sha256Hex(serialised);
794
+ if (typeof localStorage !== "undefined") {
795
+ try {
796
+ localStorage.setItem(CACHE_KEY, hash);
797
+ } catch {
798
+ }
799
+ }
800
+ return hash;
801
+ }
802
+
623
803
  // src/session.ts
624
804
  var SESSION_KEY = "mushi_session_id";
625
805
  var cachedSessionId = null;
@@ -692,16 +872,33 @@ function createRateLimiter(config = {}) {
692
872
  var ORDERED_PATTERNS = [
693
873
  { key: "ssns", regex: /\b\d{3}-\d{2}-\d{4}\b/g, replacement: "[REDACTED_SSN]" },
694
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]" },
695
887
  { key: "emails", regex: /\b[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}\b/g, replacement: "[REDACTED_EMAIL]" },
696
888
  { key: "phones", regex: /(?:\+\d{1,3}[\s.-])?\(?\d{2,4}\)?[\s.-]\d{3,4}[\s.-]\d{3,4}\b/g, replacement: "[REDACTED_PHONE]" },
697
- { 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]" }
698
891
  ];
699
892
  var DEFAULT_CONFIG = {
700
893
  emails: true,
701
894
  phones: true,
702
895
  creditCards: true,
703
896
  ssns: true,
704
- 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
705
902
  };
706
903
  function createPiiScrubber(config = {}) {
707
904
  const merged = { ...DEFAULT_CONFIG, ...config };
@@ -729,6 +926,6 @@ function scrubPii(text, config) {
729
926
  return createPiiScrubber(config).scrub(text);
730
927
  }
731
928
 
732
- export { DEFAULT_API_ENDPOINT, REGION_ENDPOINTS, captureEnvironment, createApiClient, createLogger, createOfflineQueue, createPiiScrubber, createPreFilter, createRateLimiter, getReporterToken, getSessionId, noopLogger, resolveRegionEndpoint, scrubPii };
929
+ export { DEFAULT_API_ENDPOINT, REGION_ENDPOINTS, captureEnvironment, createApiClient, createLogger, createOfflineQueue, createPiiScrubber, createPreFilter, createRateLimiter, getDeviceFingerprintHash, getReporterToken, getSessionId, noopLogger, resolveRegionEndpoint, scrubPii };
733
930
  //# sourceMappingURL=index.js.map
734
931
  //# sourceMappingURL=index.js.map