@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 +2 -0
- package/dist/index.cjs +220 -22
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +98 -2
- package/dist/index.d.ts +98 -2
- package/dist/index.js +220 -23
- package/dist/index.js.map +1 -1
- package/package.json +11 -4
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(
|
|
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(
|
|
563
|
+
function lsWrite(rows) {
|
|
444
564
|
try {
|
|
445
|
-
localStorage.setItem(LS_KEY, JSON.stringify(
|
|
565
|
+
localStorage.setItem(LS_KEY, JSON.stringify(rows));
|
|
446
566
|
} catch {
|
|
447
567
|
}
|
|
448
568
|
}
|
|
449
|
-
function lsEnqueue(report) {
|
|
450
|
-
const
|
|
451
|
-
|
|
452
|
-
lsWrite(
|
|
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
|
|
456
|
-
lsWrite(
|
|
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
|
|
607
|
+
let rows;
|
|
488
608
|
const backend = detectBackend();
|
|
489
609
|
if (backend === "indexeddb") {
|
|
490
610
|
try {
|
|
491
|
-
|
|
611
|
+
rows = await idbGetAll();
|
|
492
612
|
} catch {
|
|
493
|
-
|
|
613
|
+
rows = lsRead();
|
|
494
614
|
}
|
|
495
615
|
} else {
|
|
496
|
-
|
|
616
|
+
rows = lsRead();
|
|
497
617
|
}
|
|
498
|
-
const batch =
|
|
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
|
|
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(
|
|
507
|
-
else lsDelete(
|
|
638
|
+
if (backend === "indexeddb") await idbDelete(rowId);
|
|
639
|
+
else lsDelete(rowId);
|
|
508
640
|
} catch {
|
|
509
|
-
lsDelete(
|
|
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
|