@mushi-mushi/react-native 0.19.0 → 0.19.2

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/CHANGELOG.md CHANGED
@@ -1,5 +1,31 @@
1
1
  # @mushi-mushi/react-native
2
2
 
3
+ ## 0.19.2
4
+
5
+ ### Patch Changes
6
+
7
+ - 7b44c97: SDK fixes from automated code review.
8
+
9
+ - **Favicon trust boundary** (`@mushi-mushi/core`): `readPageFaviconHref` now returns only http(s) URLs, so a host page can't get a `data:` / `blob:` / `javascript:` favicon rendered into the widget's `<img src>`; anything else falls back to the default mark.
10
+ - **Self-hosted credential message** (`@mushi-mushi/core`, `@mushi-mushi/react-native`): the one-time 401/403 "credentials rejected" warning only links to the hosted console when the client is actually using the Cloud endpoint; self-hosted deployments get a console-agnostic message instead of a wrong domain.
11
+ - **Offline-queue data loss** (`@mushi-mushi/react-native`): `decryptQueueBlob` now decrypts any blob carrying the encrypted prefix regardless of the current `secureStorage` flag. Previously, toggling `secureStorage` from `true` to `false` returned the still-encrypted string, which failed `JSON.parse` and silently cleared the offline report queue.
12
+
13
+ - Updated dependencies [7b44c97]
14
+ - @mushi-mushi/core@1.19.2
15
+
16
+ ## 0.19.1
17
+
18
+ ### Patch Changes
19
+
20
+ - 08108e6: Setup UX overhaul: zero-paste `mushi login` browser device-auth, credential error visibility, and docs fixes.
21
+
22
+ - **CLI**: `mushi login` now implements RFC 8628 browser device-auth (zero copy-paste). Opens the console in the browser, user clicks Approve, CLI receives a session token automatically, then lists/creates a project and saves the API key. `--api-key` flag remains as the CI/non-interactive fallback.
23
+ - **Core SDK**: 401/403 responses now emit a one-time `console.error` with a clear credential-failure message and the console URL, instead of silently entering the offline retry queue.
24
+ - **React Native**: Same 401/403 credential-failure detection in `MushiProvider.submitReport` — skips enqueue and surfaces the error immediately.
25
+
26
+ - Updated dependencies [08108e6]
27
+ - @mushi-mushi/core@1.19.1
28
+
3
29
  ## 0.19.0
4
30
 
5
31
  ### Minor Changes
package/CONTRIBUTING.md CHANGED
@@ -27,12 +27,24 @@ pnpm build
27
27
 
28
28
  ```bash
29
29
  pnpm dev # Start all dev servers (admin on :6464)
30
+ ```
31
+
32
+ | Surface | URL |
33
+ | --- | --- |
34
+ | Admin console | http://localhost:6464 |
35
+ | Docs site | http://localhost:3000 |
36
+ | CLI console override | `MUSHI_CONSOLE_URL=http://localhost:6464` |
37
+
38
+ ```bash
30
39
  pnpm test # Run Vitest across all packages
31
40
  pnpm typecheck # TypeScript checks
32
41
  pnpm lint # ESLint
33
42
  pnpm format # Prettier
43
+ pnpm smoke:cli-setup # Playwright: onboarding ?setup=cli + success panel (skips headless if unsigned)
34
44
  ```
35
45
 
46
+ Set `CLI_SETUP_SMOKE_STRICT=1` to fail the smoke script when Playwright is not signed in (CI gate).
47
+
36
48
  Ad-hoc screenshots captured during UI reviews can live temporarily at the repo
37
49
  root, but root-level `*.png` files are intentionally ignored. Canonical
38
50
  screenshots that should be versioned belong under `docs/screenshots/`.
package/README.md CHANGED
@@ -106,13 +106,27 @@ import { MushiBottomSheet, MushiFloatingButton } from '@mushi-mushi/react-native
106
106
 
107
107
  ## Screenshots
108
108
 
109
- When `react-native-view-shot` is installed, the SDK captures the current screen **before** the report sheet overlays it, and shows the user a thumbnail they can remove before submitting. Enabled by default; turn off with `capture: { screenshot: false }`.
109
+ When `react-native-view-shot` is installed, the SDK captures the current screen **before** the report sheet overlays it, and shows the user a **thumbnail preview** they can remove before submitting. A configurable **privacy caption** (`widget.screenshotSensitiveHint`) appears under the preview — default copy reminds reporters to drop balances or PII. Enabled by default; turn off capture with `capture: { screenshot: false }`.
110
+
111
+ ```tsx
112
+ <MushiProvider
113
+ config={{
114
+ widget: {
115
+ // true (default) | "Custom compliance copy" | false (hide caption only)
116
+ screenshotSensitiveHint: true,
117
+ },
118
+ capture: { screenshot: true },
119
+ }}
120
+ >
121
+ ```
110
122
 
111
123
  ```bash
112
124
  npx expo install react-native-view-shot # v4+ on Expo SDK 55; v5+ for Fabric / New Architecture
113
125
  ```
114
126
 
115
- > **Masking sensitive screens.** `captureScreen()` grabs whatever is on screen, which on a finance/health app can include balances or PII. The user always reviews and can remove the thumbnail before submit, but for screens that should never be captured, set `capture: { screenshot: false }` while they're focused, or use `expo-screen-capture` to mark the view secure. Field-level masking is on the roadmap.
127
+ > **Masking sensitive screens.** `captureScreen()` grabs whatever is on screen. The preview + Remove control is the reporter's consent gate — finance apps can keep screenshots on with a custom caption instead of disabling capture entirely. For screens that must never be photographed, set `capture: { screenshot: false }` while they're focused, or use `expo-screen-capture` to mark the view secure. Full doc: [`docs/SDK_SCREENSHOT_PREVIEW.md`](../../docs/SDK_SCREENSHOT_PREVIEW.md).
128
+
129
+ > **Metro / Hermes hosts.** The published dist uses esbuild `__require()` for optional peers. Host apps may need a postinstall patch (see yen-yen `scripts/patch-mushi.mjs`) so Metro resolves `react-native-view-shot` and `@react-native-community/netinfo`.
116
130
 
117
131
  ## Identifying the reporter
118
132
 
package/dist/index.cjs CHANGED
@@ -47,9 +47,10 @@ module.exports = __toCommonJS(index_exports);
47
47
 
48
48
  // src/provider.tsx
49
49
  var import_react3 = require("react");
50
- var import_core3 = require("@mushi-mushi/core");
50
+ var import_core5 = require("@mushi-mushi/core");
51
51
 
52
52
  // src/capture/console-capture.ts
53
+ var import_core = require("@mushi-mushi/core");
53
54
  function setupConsoleCapture(maxEntries = 100) {
54
55
  const entries = [];
55
56
  const methods = ["log", "warn", "error"];
@@ -60,7 +61,7 @@ function setupConsoleCapture(maxEntries = 100) {
60
61
  console[method] = (...args) => {
61
62
  entries.push({
62
63
  level: method,
63
- message: args.map(String).join(" ").slice(0, 500),
64
+ message: (0, import_core.scrubPii)(args.map(String).join(" ").slice(0, 500)),
64
65
  timestamp: Date.now()
65
66
  });
66
67
  if (entries.length > maxEntries) entries.shift();
@@ -189,16 +190,153 @@ function getDeviceFingerprintHash(device) {
189
190
  }
190
191
 
191
192
  // src/storage/async-storage-queue.ts
192
- var QUEUE_KEY = "@mushi:offline_queue";
193
+ var import_core2 = require("@mushi-mushi/core");
194
+
195
+ // src/storage/secure-storage.ts
196
+ var REPORTER_TOKEN_KEY = "@mushi:reporter_token";
197
+ var QUEUE_CRYPTO_KEY = "@mushi:queue_aes_key";
198
+ var LEGACY_QUEUE_KEY = "@mushi:offline_queue";
199
+ var ENCRYPTED_QUEUE_KEY = "@mushi:offline_queue_enc";
200
+ var ENCRYPTED_PREFIX = "mushi_enc_v1:";
201
+ async function loadAsyncStorage() {
202
+ try {
203
+ const mod = await import("@react-native-async-storage/async-storage");
204
+ return mod.default;
205
+ } catch {
206
+ return null;
207
+ }
208
+ }
209
+ async function loadSecureStore() {
210
+ try {
211
+ const mod = await import("expo-secure-store");
212
+ return mod.default ?? mod;
213
+ } catch {
214
+ return null;
215
+ }
216
+ }
217
+ function bytesToBase64(bytes) {
218
+ let binary = "";
219
+ for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
220
+ if (typeof btoa === "function") return btoa(binary);
221
+ const B = globalThis.Buffer;
222
+ if (B) return B.from(bytes).toString("base64");
223
+ return "";
224
+ }
225
+ function base64ToBytes(b64) {
226
+ if (typeof atob === "function") {
227
+ const binary = atob(b64);
228
+ const out = new Uint8Array(binary.length);
229
+ for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i);
230
+ return out;
231
+ }
232
+ const B = globalThis.Buffer;
233
+ if (B) return new Uint8Array(B.from(b64, "base64"));
234
+ return new Uint8Array();
235
+ }
236
+ function hasWebCrypto() {
237
+ return typeof globalThis.crypto?.subtle?.importKey === "function";
238
+ }
239
+ async function getOrCreateQueueKey(secure) {
240
+ if (!hasWebCrypto()) return null;
241
+ let rawB64 = await secure.getItemAsync(QUEUE_CRYPTO_KEY);
242
+ if (!rawB64) {
243
+ const raw2 = new Uint8Array(32);
244
+ globalThis.crypto.getRandomValues(raw2);
245
+ rawB64 = bytesToBase64(raw2);
246
+ await secure.setItemAsync(QUEUE_CRYPTO_KEY, rawB64);
247
+ }
248
+ const raw = base64ToBytes(rawB64);
249
+ return globalThis.crypto.subtle.importKey("raw", raw, { name: "AES-GCM" }, false, ["encrypt", "decrypt"]);
250
+ }
251
+ async function encryptQueueBlob(plaintext, secureStorage) {
252
+ if (!secureStorage) return plaintext;
253
+ const secure = await loadSecureStore();
254
+ if (!secure) return plaintext;
255
+ const key = await getOrCreateQueueKey(secure);
256
+ if (!key) return plaintext;
257
+ const iv = new Uint8Array(12);
258
+ globalThis.crypto.getRandomValues(iv);
259
+ const encoded = new TextEncoder().encode(plaintext);
260
+ const cipher = await globalThis.crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, encoded);
261
+ const combined = new Uint8Array(iv.length + cipher.byteLength);
262
+ combined.set(iv, 0);
263
+ combined.set(new Uint8Array(cipher), iv.length);
264
+ return ENCRYPTED_PREFIX + bytesToBase64(combined);
265
+ }
266
+ async function decryptQueueBlob(stored, _secureStorage) {
267
+ if (!stored.startsWith(ENCRYPTED_PREFIX)) return stored;
268
+ const secure = await loadSecureStore();
269
+ if (!secure || !hasWebCrypto()) return stored;
270
+ const key = await getOrCreateQueueKey(secure);
271
+ if (!key) return stored;
272
+ const combined = base64ToBytes(stored.slice(ENCRYPTED_PREFIX.length));
273
+ if (combined.length < 13) return stored;
274
+ const iv = combined.slice(0, 12);
275
+ const cipher = combined.slice(12);
276
+ try {
277
+ const plain = await globalThis.crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, cipher);
278
+ return new TextDecoder().decode(plain);
279
+ } catch {
280
+ return stored;
281
+ }
282
+ }
283
+ async function loadReporterToken(secureStorage) {
284
+ if (secureStorage) {
285
+ const secure = await loadSecureStore();
286
+ if (secure) {
287
+ const fromSecure = await secure.getItemAsync(REPORTER_TOKEN_KEY);
288
+ if (fromSecure?.startsWith("mushi_")) return fromSecure;
289
+ const storage2 = await loadAsyncStorage();
290
+ if (storage2) {
291
+ const legacy = await storage2.getItem(REPORTER_TOKEN_KEY);
292
+ if (legacy?.startsWith("mushi_")) {
293
+ await secure.setItemAsync(REPORTER_TOKEN_KEY, legacy);
294
+ await storage2.removeItem(REPORTER_TOKEN_KEY);
295
+ return legacy;
296
+ }
297
+ }
298
+ return fromSecure;
299
+ }
300
+ }
301
+ const storage = await loadAsyncStorage();
302
+ if (!storage) return null;
303
+ return storage.getItem(REPORTER_TOKEN_KEY);
304
+ }
305
+ async function saveReporterToken(token, secureStorage) {
306
+ if (secureStorage) {
307
+ const secure = await loadSecureStore();
308
+ if (secure) {
309
+ await secure.setItemAsync(REPORTER_TOKEN_KEY, token);
310
+ const storage2 = await loadAsyncStorage();
311
+ if (storage2) await storage2.removeItem(REPORTER_TOKEN_KEY);
312
+ return;
313
+ }
314
+ }
315
+ const storage = await loadAsyncStorage();
316
+ if (storage) await storage.setItem(REPORTER_TOKEN_KEY, token);
317
+ }
318
+
319
+ // src/storage/async-storage-queue.ts
320
+ var QUEUE_KEY = ENCRYPTED_QUEUE_KEY;
193
321
  var AsyncStorageQueue = class {
194
322
  maxSize;
195
323
  apiEndpoint;
196
324
  apiKey;
325
+ projectId;
326
+ sdkPackage;
327
+ sdkVersion;
328
+ getUserToken;
329
+ secureStorage;
197
330
  onSynced;
198
331
  constructor(config) {
199
332
  this.maxSize = config.maxSize ?? 50;
200
333
  this.apiEndpoint = config.apiEndpoint;
201
334
  this.apiKey = config.apiKey;
335
+ this.projectId = config.projectId;
336
+ this.sdkPackage = config.sdkPackage;
337
+ this.sdkVersion = config.sdkVersion;
338
+ this.getUserToken = config.getUserToken;
339
+ this.secureStorage = config.secureStorage !== false;
202
340
  this.onSynced = config.onSynced;
203
341
  }
204
342
  async enqueue(report) {
@@ -208,7 +346,7 @@ var AsyncStorageQueue = class {
208
346
  if (queue.length >= this.maxSize) queue.shift();
209
347
  const scrubbedReport = this.scrubReportPii(report);
210
348
  queue.push({ report: scrubbedReport, enqueuedAt: Date.now() });
211
- await AsyncStorage.setItem(QUEUE_KEY, JSON.stringify(queue));
349
+ await this.persistQueue(AsyncStorage, queue);
212
350
  }
213
351
  async flush() {
214
352
  const AsyncStorage = await this.getAsyncStorage();
@@ -218,7 +356,14 @@ var AsyncStorageQueue = class {
218
356
  let flushed = 0;
219
357
  const remaining = [];
220
358
  for (const item of queue) {
221
- const ok = await sendWithRetry(item, this.apiEndpoint, this.apiKey);
359
+ const ok = await sendWithRetry(item, {
360
+ apiEndpoint: this.apiEndpoint,
361
+ apiKey: this.apiKey,
362
+ projectId: this.projectId,
363
+ sdkPackage: this.sdkPackage,
364
+ sdkVersion: this.sdkVersion,
365
+ getUserToken: this.getUserToken
366
+ });
222
367
  if (ok) {
223
368
  flushed++;
224
369
  const reportId = item.report.id;
@@ -233,7 +378,7 @@ var AsyncStorageQueue = class {
233
378
  break;
234
379
  }
235
380
  }
236
- await AsyncStorage.setItem(QUEUE_KEY, JSON.stringify(remaining));
381
+ await this.persistQueue(AsyncStorage, remaining);
237
382
  return flushed;
238
383
  }
239
384
  async size() {
@@ -242,31 +387,43 @@ var AsyncStorageQueue = class {
242
387
  const queue = await this.getQueue(AsyncStorage);
243
388
  return queue.length;
244
389
  }
245
- // Added: PII scrubbing (Phase 2.4)
246
- // Scrubs free-text fields where users typically type emails / phones /
247
- // card numbers when describing a bug. We deliberately do NOT scrub
248
- // structured fields like `metadata.userEmail` — those are explicitly
249
- // captured by the host app via `setUser()` and the operator opts in to
250
- // collecting them. Scrubbing them here would silently break attribution.
251
390
  scrubReportPii(report) {
252
391
  const next = { ...report };
253
392
  if (typeof next.description === "string") {
254
- next.description = scrubPii(next.description);
393
+ next.description = (0, import_core2.scrubPii)(next.description);
255
394
  }
256
395
  if (typeof next.summary === "string") {
257
- next.summary = scrubPii(next.summary);
396
+ next.summary = (0, import_core2.scrubPii)(next.summary);
258
397
  }
259
398
  if (Array.isArray(next.breadcrumbs)) {
260
399
  next.breadcrumbs = next.breadcrumbs.map(
261
- (b) => typeof b?.message === "string" ? { ...b, message: scrubPii(b.message) } : b
400
+ (b) => typeof b?.message === "string" ? { ...b, message: (0, import_core2.scrubPii)(b.message) } : b
401
+ );
402
+ }
403
+ if (Array.isArray(next.consoleLogs)) {
404
+ next.consoleLogs = next.consoleLogs.map(
405
+ (entry) => typeof entry?.message === "string" ? { ...entry, message: (0, import_core2.scrubPii)(entry.message) } : entry
262
406
  );
263
407
  }
264
408
  return next;
265
409
  }
410
+ async persistQueue(storage, queue) {
411
+ const plaintext = JSON.stringify(queue);
412
+ const blob = await encryptQueueBlob(plaintext, this.secureStorage);
413
+ await storage.setItem(QUEUE_KEY, blob);
414
+ }
266
415
  async getQueue(storage) {
267
416
  try {
268
- const raw = await storage.getItem(QUEUE_KEY);
269
- return raw ? JSON.parse(raw) : [];
417
+ let raw = await storage.getItem(QUEUE_KEY);
418
+ if (!raw) {
419
+ raw = await storage.getItem(LEGACY_QUEUE_KEY);
420
+ if (raw) {
421
+ await storage.removeItem(LEGACY_QUEUE_KEY);
422
+ }
423
+ }
424
+ if (!raw) return [];
425
+ const plaintext = await decryptQueueBlob(raw, this.secureStorage);
426
+ return JSON.parse(plaintext);
270
427
  } catch {
271
428
  return [];
272
429
  }
@@ -280,56 +437,33 @@ var AsyncStorageQueue = class {
280
437
  }
281
438
  }
282
439
  };
283
- var SCRUB_PATTERNS = [
284
- [/\b\d{3}-\d{2}-\d{4}\b/g, "[REDACTED_SSN]"],
285
- [/\b(?:\d[ -]*){12,18}\d\b/g, "[REDACTED_CC]"],
286
- [/\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g, "[REDACTED_AWS_KEY]"],
287
- [
288
- /(?:aws_secret_access_key|secret_access_key)["'\s:=]+[A-Za-z0-9/+=]{40}\b/gi,
289
- "aws_secret_access_key=[REDACTED_AWS_SECRET]"
290
- ],
291
- [/\b(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{24,}\b/g, "[REDACTED_STRIPE_KEY]"],
292
- [/\bpk_(?:live|test)_[A-Za-z0-9]{24,}\b/g, "[REDACTED_STRIPE_PK]"],
293
- [/\bxox[abpor]-[A-Za-z0-9-]{10,}\b/g, "[REDACTED_SLACK_TOKEN]"],
294
- [/\bghp_[A-Za-z0-9]{36}\b/g, "[REDACTED_GITHUB_PAT]"],
295
- [/\bgithub_pat_[A-Za-z0-9_]{80,}\b/g, "[REDACTED_GITHUB_PAT]"],
296
- [/\bsk-(?:proj-)?[A-Za-z0-9_-]{20,}\b/g, "[REDACTED_OPENAI_KEY]"],
297
- [/\bsk-ant-[A-Za-z0-9_-]{20,}\b/g, "[REDACTED_ANTHROPIC_KEY]"],
298
- [/\bAIza[0-9A-Za-z_-]{35}\b/g, "[REDACTED_GOOGLE_KEY]"],
299
- [/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g, "[REDACTED_JWT]"],
300
- [/\b[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}\b/g, "[REDACTED_EMAIL]"],
301
- [
302
- /(?:\+\d{1,3}[\s.\-])?\(?\d{2,4}\)?[\s.\-]\d{3,4}[\s.\-]\d{3,4}\b/g,
303
- "[REDACTED_PHONE]"
304
- ]
305
- ];
306
- function scrubPii(text) {
307
- let result = text;
308
- for (const [regex, replacement] of SCRUB_PATTERNS) {
309
- result = result.replace(regex, replacement);
310
- }
311
- return result;
312
- }
313
- async function sendWithRetry(item, endpoint, apiKey, attempt = 0) {
440
+ async function sendWithRetry(item, ctx, attempt = 0) {
314
441
  const delay = Math.min(1e3 * Math.pow(2, attempt) + Math.random() * 500, 1e4);
442
+ const internalKind = "report-submit";
443
+ const userToken = ctx.getUserToken?.() ?? null;
315
444
  try {
316
- const res = await fetch(`${endpoint}/v1/reports`, {
445
+ const res = await fetch(`${ctx.apiEndpoint}/v1/reports`, {
317
446
  method: "POST",
318
- headers: {
319
- "Content-Type": "application/json",
320
- "X-Mushi-Api-Key": apiKey
321
- },
322
- body: JSON.stringify(item.report)
447
+ headers: (0, import_core2.buildSdkIngestHeaders)({
448
+ apiKey: ctx.apiKey,
449
+ projectId: ctx.projectId,
450
+ sdkPackage: ctx.sdkPackage,
451
+ sdkVersion: ctx.sdkVersion,
452
+ userToken,
453
+ internalKind
454
+ }),
455
+ body: JSON.stringify(item.report),
456
+ [import_core2.MUSHI_INTERNAL_INIT_MARKER]: internalKind
323
457
  });
324
458
  if ((res.status === 429 || res.status >= 500) && attempt < 3) {
325
459
  await sleep(delay);
326
- return sendWithRetry(item, endpoint, apiKey, attempt + 1);
460
+ return sendWithRetry(item, ctx, attempt + 1);
327
461
  }
328
462
  return res.ok;
329
463
  } catch {
330
464
  if (attempt < 3) {
331
465
  await sleep(delay);
332
- return sendWithRetry(item, endpoint, apiKey, attempt + 1);
466
+ return sendWithRetry(item, ctx, attempt + 1);
333
467
  }
334
468
  return false;
335
469
  }
@@ -341,7 +475,7 @@ function sleep(ms) {
341
475
  // src/components/MushiBottomSheet.tsx
342
476
  var import_react = require("react");
343
477
  var import_react_native2 = require("react-native");
344
- var import_core = require("@mushi-mushi/core");
478
+ var import_core3 = require("@mushi-mushi/core");
345
479
  var import_jsx_runtime = require("react/jsx-runtime");
346
480
  var { height: SCREEN_HEIGHT } = import_react_native2.Dimensions.get("window");
347
481
  var SHEET_HEIGHT = SCREEN_HEIGHT * 0.55;
@@ -499,7 +633,7 @@ var MushiBottomSheet = ({
499
633
  }
500
634
  };
501
635
  const activeScreenshot = screenshotDataUrl && screenshotAttached ? screenshotDataUrl : null;
502
- const pal = (0, import_core.mushiPalette)(dark ? "dark" : "light");
636
+ const pal = (0, import_core3.mushiPalette)(dark ? "dark" : "light");
503
637
  const colors = dark ? { bg: pal.paper, text: pal.ink, sub: pal.inkMuted, card: pal.paperRaised, accent: NEON.accent, accentInk: NEON.ink, border: pal.ruleStrong, backdrop: "rgba(0,0,0,0.6)", disabled: "#3a3a3c", disabledText: pal.inkFaint } : { bg: pal.paperRaised, text: pal.ink, sub: pal.inkMuted, card: pal.paper, accent: NEON.accent, accentInk: NEON.ink, border: pal.ruleStrong, backdrop: "rgba(0,0,0,0.35)", disabled: "#d1d1d6", disabledText: pal.inkFaint };
504
638
  const canSubmit = !!category && description.trim().length > 0 && phase === "form";
505
639
  return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react_native2.Modal, { visible, transparent: true, animationType: "none", statusBarTranslucent: true, children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
@@ -894,7 +1028,7 @@ var s = import_react_native2.StyleSheet.create({
894
1028
  // src/components/MushiFloatingButton.tsx
895
1029
  var import_react2 = require("react");
896
1030
  var import_react_native3 = require("react-native");
897
- var import_core2 = require("@mushi-mushi/core");
1031
+ var import_core4 = require("@mushi-mushi/core");
898
1032
  var import_jsx_runtime2 = require("react/jsx-runtime");
899
1033
  var FAB_SIZE = 56;
900
1034
  var EDGE_PADDING = 12;
@@ -946,7 +1080,7 @@ var MushiFloatingButton = ({
946
1080
  const currentX = (0, import_react2.useRef)(baseX);
947
1081
  const currentY = (0, import_react2.useRef)(0);
948
1082
  const isDragging = (0, import_react2.useRef)(false);
949
- const accentColor = accent ?? (0, import_core2.mushiPalette)(dark ? "dark" : "light").accent;
1083
+ const accentColor = accent ?? (0, import_core4.mushiPalette)(dark ? "dark" : "light").accent;
950
1084
  (0, import_react2.useEffect)(() => {
951
1085
  if (!persist) return;
952
1086
  void loadStoredPos().then((pos) => {
@@ -1056,7 +1190,7 @@ var MushiFloatingButton = ({
1056
1190
  onStartShouldSetResponder: () => !draggable,
1057
1191
  onResponderGrant: handlePressIn,
1058
1192
  onResponderRelease: handlePressOut,
1059
- children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_react_native3.Animated.Text, { style: s2.emoji, children: import_core2.MUSHI_COPY.triggerText })
1193
+ children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_react_native3.Animated.Text, { style: s2.emoji, children: import_core4.MUSHI_COPY.triggerText })
1060
1194
  }
1061
1195
  );
1062
1196
  };
@@ -1081,7 +1215,7 @@ var s2 = import_react_native3.StyleSheet.create({
1081
1215
 
1082
1216
  // src/version.ts
1083
1217
  var MUSHI_SDK_PACKAGE = "@mushi-mushi/react-native";
1084
- var MUSHI_SDK_VERSION = "0.19.0" ? "0.19.0" : "0.17.0";
1218
+ var MUSHI_SDK_VERSION = "0.19.2" ? "0.19.2" : "0.17.0";
1085
1219
 
1086
1220
  // src/provider.tsx
1087
1221
  var import_jsx_runtime3 = require("react/jsx-runtime");
@@ -1128,9 +1262,9 @@ function MushiProvider({ children, config: configProp, ...barePropConfig }) {
1128
1262
  const networkRef = (0, import_react3.useRef)(null);
1129
1263
  const queueRef = (0, import_react3.useRef)(null);
1130
1264
  const apiClientRef = (0, import_react3.useRef)(null);
1131
- const sessionIdRef = (0, import_react3.useRef)(`sess_${(0, import_core3.newUuid)()}`);
1265
+ const sessionIdRef = (0, import_react3.useRef)(`sess_${(0, import_core5.newUuid)()}`);
1132
1266
  const breadcrumbsRef = (0, import_react3.useRef)(
1133
- (0, import_core3.createBreadcrumbBuffer)({ max: config.capture?.maxBreadcrumbs ?? 50 })
1267
+ (0, import_core5.createBreadcrumbBuffer)({ max: config.capture?.maxBreadcrumbs ?? 50 })
1134
1268
  );
1135
1269
  const [sheetVisible, setSheetVisible] = (0, import_react3.useState)(false);
1136
1270
  const [sheetScreenshot, setSheetScreenshot] = (0, import_react3.useState)(null);
@@ -1153,7 +1287,7 @@ function MushiProvider({ children, config: configProp, ...barePropConfig }) {
1153
1287
  if (config.endpoint && !config.apiEndpoint && __DEV__) {
1154
1288
  console.warn("[MushiProvider] `endpoint` is deprecated \u2014 use `apiEndpoint` instead.");
1155
1289
  }
1156
- const apiEndpoint = rawEndpoint ?? import_core3.DEFAULT_API_ENDPOINT;
1290
+ const apiEndpoint = rawEndpoint ?? import_core5.DEFAULT_API_ENDPOINT;
1157
1291
  (0, import_react3.useEffect)(() => {
1158
1292
  if (config.capture?.console !== false) {
1159
1293
  consoleRef.current = setupConsoleCapture(config.capture?.maxConsoleEntries);
@@ -1161,7 +1295,7 @@ function MushiProvider({ children, config: configProp, ...barePropConfig }) {
1161
1295
  if (config.capture?.network !== false) {
1162
1296
  networkRef.current = setupNetworkCapture(config.capture?.maxNetworkEntries, apiEndpoint);
1163
1297
  }
1164
- apiClientRef.current = (0, import_core3.createApiClient)({
1298
+ apiClientRef.current = (0, import_core5.createApiClient)({
1165
1299
  projectId: config.projectId,
1166
1300
  apiKey: config.apiKey,
1167
1301
  apiEndpoint,
@@ -1173,6 +1307,11 @@ function MushiProvider({ children, config: configProp, ...barePropConfig }) {
1173
1307
  maxSize: config.storage?.maxQueueSize,
1174
1308
  apiEndpoint,
1175
1309
  apiKey: config.apiKey,
1310
+ projectId: config.projectId,
1311
+ sdkPackage: MUSHI_SDK_PACKAGE,
1312
+ sdkVersion: MUSHI_SDK_VERSION,
1313
+ getUserToken: () => userTokenRef.current,
1314
+ secureStorage: config.storage?.secureStorage,
1176
1315
  // `onReportSynced` fires here — when a previously-queued report actually
1177
1316
  // drains to the server — not on the direct-submit path (where it would be
1178
1317
  // indistinguishable from `onReportSubmitted` and thus meaningless).
@@ -1187,21 +1326,20 @@ function MushiProvider({ children, config: configProp, ...barePropConfig }) {
1187
1326
  data: { sdk: MUSHI_SDK_PACKAGE, version: MUSHI_SDK_VERSION }
1188
1327
  });
1189
1328
  (async () => {
1190
- const TOKEN_KEY = "@mushi:reporter_token";
1191
1329
  try {
1192
- const AsyncStorage = (await import("@react-native-async-storage/async-storage")).default;
1193
- const existing = await AsyncStorage.getItem(TOKEN_KEY);
1330
+ const secureStorage = config.storage?.secureStorage !== false;
1331
+ const existing = await loadReporterToken(secureStorage);
1194
1332
  if (existing && existing.startsWith("mushi_")) {
1195
1333
  reporterTokenRef.current = existing;
1196
1334
  } else {
1197
- const fresh = `mushi_${(0, import_core3.newUuid)()}`;
1335
+ const fresh = `mushi_${(0, import_core5.newUuid)()}`;
1198
1336
  reporterTokenRef.current = fresh;
1199
- await AsyncStorage.setItem(TOKEN_KEY, fresh);
1337
+ await saveReporterToken(fresh, secureStorage);
1200
1338
  }
1201
1339
  resolveReporterTokenReady();
1202
1340
  } catch {
1203
1341
  if (reporterTokenRef.current === `rn-${config.projectId}-anon`) {
1204
- reporterTokenRef.current = `mushi_${(0, import_core3.newUuid)()}`;
1342
+ reporterTokenRef.current = `mushi_${(0, import_core5.newUuid)()}`;
1205
1343
  }
1206
1344
  resolveReporterTokenReady();
1207
1345
  }
@@ -1311,7 +1449,10 @@ function MushiProvider({ children, config: configProp, ...barePropConfig }) {
1311
1449
  if (screenRef.current?.route) contextMeta.screenRoute = screenRef.current.route;
1312
1450
  if (screenRef.current?.feature) contextMeta.screenFeature = screenRef.current.feature;
1313
1451
  const merged = Object.keys(metadataRef.current).length > 0 ? { ...contextMeta, ...metadataRef.current } : Object.keys(contextMeta).length > 0 ? contextMeta : void 0;
1314
- const breadcrumbs = breadcrumbsRef.current.getAll();
1452
+ const breadcrumbs = breadcrumbsRef.current.getAll().map((c) => ({
1453
+ ...c,
1454
+ message: (0, import_core5.scrubPii)(c.message)
1455
+ }));
1315
1456
  const timeline = breadcrumbs.map((c) => ({
1316
1457
  ts: c.timestamp,
1317
1458
  kind: breadcrumbToTimelineKind(c.category),
@@ -1319,10 +1460,10 @@ function MushiProvider({ children, config: configProp, ...barePropConfig }) {
1319
1460
  }));
1320
1461
  const fingerprintHash = getDeviceFingerprintHash(deviceInfo);
1321
1462
  const report = {
1322
- id: (0, import_core3.newUuid)(),
1463
+ id: (0, import_core5.newUuid)(),
1323
1464
  projectId: config.projectId,
1324
1465
  category: data.category,
1325
- description: data.description,
1466
+ description: (0, import_core5.scrubPii)(data.description),
1326
1467
  environment: {
1327
1468
  userAgent: deviceInfo.systemName ?? "ReactNative",
1328
1469
  platform: deviceInfo.platform ?? "mobile",
@@ -1333,7 +1474,10 @@ function MushiProvider({ children, config: configProp, ...barePropConfig }) {
1333
1474
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1334
1475
  timezone: deviceInfo.timezone ?? "UTC"
1335
1476
  },
1336
- consoleLogs: consoleRef.current?.getEntries() ?? [],
1477
+ consoleLogs: (consoleRef.current?.getEntries() ?? []).map((entry) => ({
1478
+ ...entry,
1479
+ message: (0, import_core5.scrubPii)(entry.message)
1480
+ })),
1337
1481
  networkLogs: networkRef.current?.getEntries().map((entry) => ({
1338
1482
  ...entry,
1339
1483
  status: entry.status ?? 0
@@ -1354,6 +1498,14 @@ function MushiProvider({ children, config: configProp, ...barePropConfig }) {
1354
1498
  if (!client) return;
1355
1499
  const result = await client.submitReport(report);
1356
1500
  if (!result.ok) {
1501
+ const isCredentialError = result.error?.code === "HTTP_401" || result.error?.code === "HTTP_403" || result.error?.code?.includes("UNAUTHORIZED") || result.error?.code?.includes("FORBIDDEN");
1502
+ if (isCredentialError) {
1503
+ const where = apiEndpoint.replace(/\/$/, "") === import_core5.DEFAULT_API_ENDPOINT ? "Get the correct values at: https://kensaur.us/mushi-mushi/admin/projects" : "Get the correct values from your Mushi console's Projects page.";
1504
+ console.error(
1505
+ '[Mushi] Credentials rejected. Check your projectId and apiKey (must have "report:write" scope). ' + where
1506
+ );
1507
+ return;
1508
+ }
1357
1509
  await queueRef.current?.enqueue(report);
1358
1510
  config.callbacks?.onReportQueued?.(report.id);
1359
1511
  } else {
@@ -1416,7 +1568,7 @@ function MushiProvider({ children, config: configProp, ...barePropConfig }) {
1416
1568
  identifyWithToken(token) {
1417
1569
  userTokenRef.current = token && typeof token === "string" ? token : null;
1418
1570
  if (userTokenRef.current) {
1419
- const claims = (0, import_core3.parseIdentityToken)(userTokenRef.current);
1571
+ const claims = (0, import_core5.parseIdentityToken)(userTokenRef.current);
1420
1572
  if (claims?.sub) {
1421
1573
  userRef.current = {
1422
1574
  id: claims.sub,
package/dist/index.d.cts CHANGED
@@ -98,6 +98,8 @@ interface MushiRNConfig {
98
98
  storage?: {
99
99
  maxQueueSize?: number;
100
100
  retryIntervalMs?: number;
101
+ /** Encrypt offline queue + store reporter token in SecureStore when available. Default true. */
102
+ secureStorage?: boolean;
101
103
  };
102
104
  rewards?: MushiRewardsConfig;
103
105
  }
@@ -298,22 +300,35 @@ declare function useNavigationCapture(navigationRef: {
298
300
  addListener?: (event: string, cb: () => void) => () => void;
299
301
  } | null): () => NavigationEntry[];
300
302
 
303
+ interface AsyncStorageQueueConfig {
304
+ maxSize?: number;
305
+ apiEndpoint: string;
306
+ apiKey: string;
307
+ projectId: string;
308
+ sdkPackage?: string;
309
+ sdkVersion?: string;
310
+ getUserToken?: () => string | null | undefined;
311
+ /** When true (default), encrypt queue + store reporter token in SecureStore when available. */
312
+ secureStorage?: boolean;
313
+ /** Fired once per queued report that drains successfully to the server. */
314
+ onSynced?: (reportId: string) => void;
315
+ }
301
316
  declare class AsyncStorageQueue {
302
317
  private maxSize;
303
318
  private apiEndpoint;
304
319
  private apiKey;
320
+ private projectId;
321
+ private sdkPackage?;
322
+ private sdkVersion?;
323
+ private getUserToken?;
324
+ private secureStorage;
305
325
  private onSynced?;
306
- constructor(config: {
307
- maxSize?: number;
308
- apiEndpoint: string;
309
- apiKey: string;
310
- /** Fired once per queued report that drains successfully to the server. */
311
- onSynced?: (reportId: string) => void;
312
- });
326
+ constructor(config: AsyncStorageQueueConfig);
313
327
  enqueue(report: object): Promise<void>;
314
328
  flush(): Promise<number>;
315
329
  size(): Promise<number>;
316
330
  private scrubReportPii;
331
+ private persistQueue;
317
332
  private getQueue;
318
333
  private getAsyncStorage;
319
334
  }
package/dist/index.d.ts CHANGED
@@ -98,6 +98,8 @@ interface MushiRNConfig {
98
98
  storage?: {
99
99
  maxQueueSize?: number;
100
100
  retryIntervalMs?: number;
101
+ /** Encrypt offline queue + store reporter token in SecureStore when available. Default true. */
102
+ secureStorage?: boolean;
101
103
  };
102
104
  rewards?: MushiRewardsConfig;
103
105
  }
@@ -298,22 +300,35 @@ declare function useNavigationCapture(navigationRef: {
298
300
  addListener?: (event: string, cb: () => void) => () => void;
299
301
  } | null): () => NavigationEntry[];
300
302
 
303
+ interface AsyncStorageQueueConfig {
304
+ maxSize?: number;
305
+ apiEndpoint: string;
306
+ apiKey: string;
307
+ projectId: string;
308
+ sdkPackage?: string;
309
+ sdkVersion?: string;
310
+ getUserToken?: () => string | null | undefined;
311
+ /** When true (default), encrypt queue + store reporter token in SecureStore when available. */
312
+ secureStorage?: boolean;
313
+ /** Fired once per queued report that drains successfully to the server. */
314
+ onSynced?: (reportId: string) => void;
315
+ }
301
316
  declare class AsyncStorageQueue {
302
317
  private maxSize;
303
318
  private apiEndpoint;
304
319
  private apiKey;
320
+ private projectId;
321
+ private sdkPackage?;
322
+ private sdkVersion?;
323
+ private getUserToken?;
324
+ private secureStorage;
305
325
  private onSynced?;
306
- constructor(config: {
307
- maxSize?: number;
308
- apiEndpoint: string;
309
- apiKey: string;
310
- /** Fired once per queued report that drains successfully to the server. */
311
- onSynced?: (reportId: string) => void;
312
- });
326
+ constructor(config: AsyncStorageQueueConfig);
313
327
  enqueue(report: object): Promise<void>;
314
328
  flush(): Promise<number>;
315
329
  size(): Promise<number>;
316
330
  private scrubReportPii;
331
+ private persistQueue;
317
332
  private getQueue;
318
333
  private getAsyncStorage;
319
334
  }
package/dist/index.js CHANGED
@@ -20,10 +20,12 @@ import {
20
20
  createBreadcrumbBuffer,
21
21
  DEFAULT_API_ENDPOINT,
22
22
  newUuid,
23
- parseIdentityToken
23
+ parseIdentityToken,
24
+ scrubPii as scrubPii3
24
25
  } from "@mushi-mushi/core";
25
26
 
26
27
  // src/capture/console-capture.ts
28
+ import { scrubPii } from "@mushi-mushi/core";
27
29
  function setupConsoleCapture(maxEntries = 100) {
28
30
  const entries = [];
29
31
  const methods = ["log", "warn", "error"];
@@ -34,7 +36,7 @@ function setupConsoleCapture(maxEntries = 100) {
34
36
  console[method] = (...args) => {
35
37
  entries.push({
36
38
  level: method,
37
- message: args.map(String).join(" ").slice(0, 500),
39
+ message: scrubPii(args.map(String).join(" ").slice(0, 500)),
38
40
  timestamp: Date.now()
39
41
  });
40
42
  if (entries.length > maxEntries) entries.shift();
@@ -163,16 +165,157 @@ function getDeviceFingerprintHash(device) {
163
165
  }
164
166
 
165
167
  // src/storage/async-storage-queue.ts
166
- var QUEUE_KEY = "@mushi:offline_queue";
168
+ import {
169
+ buildSdkIngestHeaders,
170
+ MUSHI_INTERNAL_INIT_MARKER,
171
+ scrubPii as scrubPii2
172
+ } from "@mushi-mushi/core";
173
+
174
+ // src/storage/secure-storage.ts
175
+ var REPORTER_TOKEN_KEY = "@mushi:reporter_token";
176
+ var QUEUE_CRYPTO_KEY = "@mushi:queue_aes_key";
177
+ var LEGACY_QUEUE_KEY = "@mushi:offline_queue";
178
+ var ENCRYPTED_QUEUE_KEY = "@mushi:offline_queue_enc";
179
+ var ENCRYPTED_PREFIX = "mushi_enc_v1:";
180
+ async function loadAsyncStorage() {
181
+ try {
182
+ const mod = await import("@react-native-async-storage/async-storage");
183
+ return mod.default;
184
+ } catch {
185
+ return null;
186
+ }
187
+ }
188
+ async function loadSecureStore() {
189
+ try {
190
+ const mod = await import("expo-secure-store");
191
+ return mod.default ?? mod;
192
+ } catch {
193
+ return null;
194
+ }
195
+ }
196
+ function bytesToBase64(bytes) {
197
+ let binary = "";
198
+ for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
199
+ if (typeof btoa === "function") return btoa(binary);
200
+ const B = globalThis.Buffer;
201
+ if (B) return B.from(bytes).toString("base64");
202
+ return "";
203
+ }
204
+ function base64ToBytes(b64) {
205
+ if (typeof atob === "function") {
206
+ const binary = atob(b64);
207
+ const out = new Uint8Array(binary.length);
208
+ for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i);
209
+ return out;
210
+ }
211
+ const B = globalThis.Buffer;
212
+ if (B) return new Uint8Array(B.from(b64, "base64"));
213
+ return new Uint8Array();
214
+ }
215
+ function hasWebCrypto() {
216
+ return typeof globalThis.crypto?.subtle?.importKey === "function";
217
+ }
218
+ async function getOrCreateQueueKey(secure) {
219
+ if (!hasWebCrypto()) return null;
220
+ let rawB64 = await secure.getItemAsync(QUEUE_CRYPTO_KEY);
221
+ if (!rawB64) {
222
+ const raw2 = new Uint8Array(32);
223
+ globalThis.crypto.getRandomValues(raw2);
224
+ rawB64 = bytesToBase64(raw2);
225
+ await secure.setItemAsync(QUEUE_CRYPTO_KEY, rawB64);
226
+ }
227
+ const raw = base64ToBytes(rawB64);
228
+ return globalThis.crypto.subtle.importKey("raw", raw, { name: "AES-GCM" }, false, ["encrypt", "decrypt"]);
229
+ }
230
+ async function encryptQueueBlob(plaintext, secureStorage) {
231
+ if (!secureStorage) return plaintext;
232
+ const secure = await loadSecureStore();
233
+ if (!secure) return plaintext;
234
+ const key = await getOrCreateQueueKey(secure);
235
+ if (!key) return plaintext;
236
+ const iv = new Uint8Array(12);
237
+ globalThis.crypto.getRandomValues(iv);
238
+ const encoded = new TextEncoder().encode(plaintext);
239
+ const cipher = await globalThis.crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, encoded);
240
+ const combined = new Uint8Array(iv.length + cipher.byteLength);
241
+ combined.set(iv, 0);
242
+ combined.set(new Uint8Array(cipher), iv.length);
243
+ return ENCRYPTED_PREFIX + bytesToBase64(combined);
244
+ }
245
+ async function decryptQueueBlob(stored, _secureStorage) {
246
+ if (!stored.startsWith(ENCRYPTED_PREFIX)) return stored;
247
+ const secure = await loadSecureStore();
248
+ if (!secure || !hasWebCrypto()) return stored;
249
+ const key = await getOrCreateQueueKey(secure);
250
+ if (!key) return stored;
251
+ const combined = base64ToBytes(stored.slice(ENCRYPTED_PREFIX.length));
252
+ if (combined.length < 13) return stored;
253
+ const iv = combined.slice(0, 12);
254
+ const cipher = combined.slice(12);
255
+ try {
256
+ const plain = await globalThis.crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, cipher);
257
+ return new TextDecoder().decode(plain);
258
+ } catch {
259
+ return stored;
260
+ }
261
+ }
262
+ async function loadReporterToken(secureStorage) {
263
+ if (secureStorage) {
264
+ const secure = await loadSecureStore();
265
+ if (secure) {
266
+ const fromSecure = await secure.getItemAsync(REPORTER_TOKEN_KEY);
267
+ if (fromSecure?.startsWith("mushi_")) return fromSecure;
268
+ const storage2 = await loadAsyncStorage();
269
+ if (storage2) {
270
+ const legacy = await storage2.getItem(REPORTER_TOKEN_KEY);
271
+ if (legacy?.startsWith("mushi_")) {
272
+ await secure.setItemAsync(REPORTER_TOKEN_KEY, legacy);
273
+ await storage2.removeItem(REPORTER_TOKEN_KEY);
274
+ return legacy;
275
+ }
276
+ }
277
+ return fromSecure;
278
+ }
279
+ }
280
+ const storage = await loadAsyncStorage();
281
+ if (!storage) return null;
282
+ return storage.getItem(REPORTER_TOKEN_KEY);
283
+ }
284
+ async function saveReporterToken(token, secureStorage) {
285
+ if (secureStorage) {
286
+ const secure = await loadSecureStore();
287
+ if (secure) {
288
+ await secure.setItemAsync(REPORTER_TOKEN_KEY, token);
289
+ const storage2 = await loadAsyncStorage();
290
+ if (storage2) await storage2.removeItem(REPORTER_TOKEN_KEY);
291
+ return;
292
+ }
293
+ }
294
+ const storage = await loadAsyncStorage();
295
+ if (storage) await storage.setItem(REPORTER_TOKEN_KEY, token);
296
+ }
297
+
298
+ // src/storage/async-storage-queue.ts
299
+ var QUEUE_KEY = ENCRYPTED_QUEUE_KEY;
167
300
  var AsyncStorageQueue = class {
168
301
  maxSize;
169
302
  apiEndpoint;
170
303
  apiKey;
304
+ projectId;
305
+ sdkPackage;
306
+ sdkVersion;
307
+ getUserToken;
308
+ secureStorage;
171
309
  onSynced;
172
310
  constructor(config) {
173
311
  this.maxSize = config.maxSize ?? 50;
174
312
  this.apiEndpoint = config.apiEndpoint;
175
313
  this.apiKey = config.apiKey;
314
+ this.projectId = config.projectId;
315
+ this.sdkPackage = config.sdkPackage;
316
+ this.sdkVersion = config.sdkVersion;
317
+ this.getUserToken = config.getUserToken;
318
+ this.secureStorage = config.secureStorage !== false;
176
319
  this.onSynced = config.onSynced;
177
320
  }
178
321
  async enqueue(report) {
@@ -182,7 +325,7 @@ var AsyncStorageQueue = class {
182
325
  if (queue.length >= this.maxSize) queue.shift();
183
326
  const scrubbedReport = this.scrubReportPii(report);
184
327
  queue.push({ report: scrubbedReport, enqueuedAt: Date.now() });
185
- await AsyncStorage.setItem(QUEUE_KEY, JSON.stringify(queue));
328
+ await this.persistQueue(AsyncStorage, queue);
186
329
  }
187
330
  async flush() {
188
331
  const AsyncStorage = await this.getAsyncStorage();
@@ -192,7 +335,14 @@ var AsyncStorageQueue = class {
192
335
  let flushed = 0;
193
336
  const remaining = [];
194
337
  for (const item of queue) {
195
- const ok = await sendWithRetry(item, this.apiEndpoint, this.apiKey);
338
+ const ok = await sendWithRetry(item, {
339
+ apiEndpoint: this.apiEndpoint,
340
+ apiKey: this.apiKey,
341
+ projectId: this.projectId,
342
+ sdkPackage: this.sdkPackage,
343
+ sdkVersion: this.sdkVersion,
344
+ getUserToken: this.getUserToken
345
+ });
196
346
  if (ok) {
197
347
  flushed++;
198
348
  const reportId = item.report.id;
@@ -207,7 +357,7 @@ var AsyncStorageQueue = class {
207
357
  break;
208
358
  }
209
359
  }
210
- await AsyncStorage.setItem(QUEUE_KEY, JSON.stringify(remaining));
360
+ await this.persistQueue(AsyncStorage, remaining);
211
361
  return flushed;
212
362
  }
213
363
  async size() {
@@ -216,31 +366,43 @@ var AsyncStorageQueue = class {
216
366
  const queue = await this.getQueue(AsyncStorage);
217
367
  return queue.length;
218
368
  }
219
- // Added: PII scrubbing (Phase 2.4)
220
- // Scrubs free-text fields where users typically type emails / phones /
221
- // card numbers when describing a bug. We deliberately do NOT scrub
222
- // structured fields like `metadata.userEmail` — those are explicitly
223
- // captured by the host app via `setUser()` and the operator opts in to
224
- // collecting them. Scrubbing them here would silently break attribution.
225
369
  scrubReportPii(report) {
226
370
  const next = { ...report };
227
371
  if (typeof next.description === "string") {
228
- next.description = scrubPii(next.description);
372
+ next.description = scrubPii2(next.description);
229
373
  }
230
374
  if (typeof next.summary === "string") {
231
- next.summary = scrubPii(next.summary);
375
+ next.summary = scrubPii2(next.summary);
232
376
  }
233
377
  if (Array.isArray(next.breadcrumbs)) {
234
378
  next.breadcrumbs = next.breadcrumbs.map(
235
- (b) => typeof b?.message === "string" ? { ...b, message: scrubPii(b.message) } : b
379
+ (b) => typeof b?.message === "string" ? { ...b, message: scrubPii2(b.message) } : b
380
+ );
381
+ }
382
+ if (Array.isArray(next.consoleLogs)) {
383
+ next.consoleLogs = next.consoleLogs.map(
384
+ (entry) => typeof entry?.message === "string" ? { ...entry, message: scrubPii2(entry.message) } : entry
236
385
  );
237
386
  }
238
387
  return next;
239
388
  }
389
+ async persistQueue(storage, queue) {
390
+ const plaintext = JSON.stringify(queue);
391
+ const blob = await encryptQueueBlob(plaintext, this.secureStorage);
392
+ await storage.setItem(QUEUE_KEY, blob);
393
+ }
240
394
  async getQueue(storage) {
241
395
  try {
242
- const raw = await storage.getItem(QUEUE_KEY);
243
- return raw ? JSON.parse(raw) : [];
396
+ let raw = await storage.getItem(QUEUE_KEY);
397
+ if (!raw) {
398
+ raw = await storage.getItem(LEGACY_QUEUE_KEY);
399
+ if (raw) {
400
+ await storage.removeItem(LEGACY_QUEUE_KEY);
401
+ }
402
+ }
403
+ if (!raw) return [];
404
+ const plaintext = await decryptQueueBlob(raw, this.secureStorage);
405
+ return JSON.parse(plaintext);
244
406
  } catch {
245
407
  return [];
246
408
  }
@@ -254,56 +416,33 @@ var AsyncStorageQueue = class {
254
416
  }
255
417
  }
256
418
  };
257
- var SCRUB_PATTERNS = [
258
- [/\b\d{3}-\d{2}-\d{4}\b/g, "[REDACTED_SSN]"],
259
- [/\b(?:\d[ -]*){12,18}\d\b/g, "[REDACTED_CC]"],
260
- [/\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g, "[REDACTED_AWS_KEY]"],
261
- [
262
- /(?:aws_secret_access_key|secret_access_key)["'\s:=]+[A-Za-z0-9/+=]{40}\b/gi,
263
- "aws_secret_access_key=[REDACTED_AWS_SECRET]"
264
- ],
265
- [/\b(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{24,}\b/g, "[REDACTED_STRIPE_KEY]"],
266
- [/\bpk_(?:live|test)_[A-Za-z0-9]{24,}\b/g, "[REDACTED_STRIPE_PK]"],
267
- [/\bxox[abpor]-[A-Za-z0-9-]{10,}\b/g, "[REDACTED_SLACK_TOKEN]"],
268
- [/\bghp_[A-Za-z0-9]{36}\b/g, "[REDACTED_GITHUB_PAT]"],
269
- [/\bgithub_pat_[A-Za-z0-9_]{80,}\b/g, "[REDACTED_GITHUB_PAT]"],
270
- [/\bsk-(?:proj-)?[A-Za-z0-9_-]{20,}\b/g, "[REDACTED_OPENAI_KEY]"],
271
- [/\bsk-ant-[A-Za-z0-9_-]{20,}\b/g, "[REDACTED_ANTHROPIC_KEY]"],
272
- [/\bAIza[0-9A-Za-z_-]{35}\b/g, "[REDACTED_GOOGLE_KEY]"],
273
- [/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g, "[REDACTED_JWT]"],
274
- [/\b[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}\b/g, "[REDACTED_EMAIL]"],
275
- [
276
- /(?:\+\d{1,3}[\s.\-])?\(?\d{2,4}\)?[\s.\-]\d{3,4}[\s.\-]\d{3,4}\b/g,
277
- "[REDACTED_PHONE]"
278
- ]
279
- ];
280
- function scrubPii(text) {
281
- let result = text;
282
- for (const [regex, replacement] of SCRUB_PATTERNS) {
283
- result = result.replace(regex, replacement);
284
- }
285
- return result;
286
- }
287
- async function sendWithRetry(item, endpoint, apiKey, attempt = 0) {
419
+ async function sendWithRetry(item, ctx, attempt = 0) {
288
420
  const delay = Math.min(1e3 * Math.pow(2, attempt) + Math.random() * 500, 1e4);
421
+ const internalKind = "report-submit";
422
+ const userToken = ctx.getUserToken?.() ?? null;
289
423
  try {
290
- const res = await fetch(`${endpoint}/v1/reports`, {
424
+ const res = await fetch(`${ctx.apiEndpoint}/v1/reports`, {
291
425
  method: "POST",
292
- headers: {
293
- "Content-Type": "application/json",
294
- "X-Mushi-Api-Key": apiKey
295
- },
296
- body: JSON.stringify(item.report)
426
+ headers: buildSdkIngestHeaders({
427
+ apiKey: ctx.apiKey,
428
+ projectId: ctx.projectId,
429
+ sdkPackage: ctx.sdkPackage,
430
+ sdkVersion: ctx.sdkVersion,
431
+ userToken,
432
+ internalKind
433
+ }),
434
+ body: JSON.stringify(item.report),
435
+ [MUSHI_INTERNAL_INIT_MARKER]: internalKind
297
436
  });
298
437
  if ((res.status === 429 || res.status >= 500) && attempt < 3) {
299
438
  await sleep(delay);
300
- return sendWithRetry(item, endpoint, apiKey, attempt + 1);
439
+ return sendWithRetry(item, ctx, attempt + 1);
301
440
  }
302
441
  return res.ok;
303
442
  } catch {
304
443
  if (attempt < 3) {
305
444
  await sleep(delay);
306
- return sendWithRetry(item, endpoint, apiKey, attempt + 1);
445
+ return sendWithRetry(item, ctx, attempt + 1);
307
446
  }
308
447
  return false;
309
448
  }
@@ -1082,7 +1221,7 @@ var s2 = StyleSheet2.create({
1082
1221
 
1083
1222
  // src/version.ts
1084
1223
  var MUSHI_SDK_PACKAGE = "@mushi-mushi/react-native";
1085
- var MUSHI_SDK_VERSION = "0.19.0" ? "0.19.0" : "0.17.0";
1224
+ var MUSHI_SDK_VERSION = "0.19.2" ? "0.19.2" : "0.17.0";
1086
1225
 
1087
1226
  // src/provider.tsx
1088
1227
  import { jsx as jsx3, jsxs as jsxs2 } from "react/jsx-runtime";
@@ -1174,6 +1313,11 @@ function MushiProvider({ children, config: configProp, ...barePropConfig }) {
1174
1313
  maxSize: config.storage?.maxQueueSize,
1175
1314
  apiEndpoint,
1176
1315
  apiKey: config.apiKey,
1316
+ projectId: config.projectId,
1317
+ sdkPackage: MUSHI_SDK_PACKAGE,
1318
+ sdkVersion: MUSHI_SDK_VERSION,
1319
+ getUserToken: () => userTokenRef.current,
1320
+ secureStorage: config.storage?.secureStorage,
1177
1321
  // `onReportSynced` fires here — when a previously-queued report actually
1178
1322
  // drains to the server — not on the direct-submit path (where it would be
1179
1323
  // indistinguishable from `onReportSubmitted` and thus meaningless).
@@ -1188,16 +1332,15 @@ function MushiProvider({ children, config: configProp, ...barePropConfig }) {
1188
1332
  data: { sdk: MUSHI_SDK_PACKAGE, version: MUSHI_SDK_VERSION }
1189
1333
  });
1190
1334
  (async () => {
1191
- const TOKEN_KEY = "@mushi:reporter_token";
1192
1335
  try {
1193
- const AsyncStorage = (await import("@react-native-async-storage/async-storage")).default;
1194
- const existing = await AsyncStorage.getItem(TOKEN_KEY);
1336
+ const secureStorage = config.storage?.secureStorage !== false;
1337
+ const existing = await loadReporterToken(secureStorage);
1195
1338
  if (existing && existing.startsWith("mushi_")) {
1196
1339
  reporterTokenRef.current = existing;
1197
1340
  } else {
1198
1341
  const fresh = `mushi_${newUuid()}`;
1199
1342
  reporterTokenRef.current = fresh;
1200
- await AsyncStorage.setItem(TOKEN_KEY, fresh);
1343
+ await saveReporterToken(fresh, secureStorage);
1201
1344
  }
1202
1345
  resolveReporterTokenReady();
1203
1346
  } catch {
@@ -1312,7 +1455,10 @@ function MushiProvider({ children, config: configProp, ...barePropConfig }) {
1312
1455
  if (screenRef.current?.route) contextMeta.screenRoute = screenRef.current.route;
1313
1456
  if (screenRef.current?.feature) contextMeta.screenFeature = screenRef.current.feature;
1314
1457
  const merged = Object.keys(metadataRef.current).length > 0 ? { ...contextMeta, ...metadataRef.current } : Object.keys(contextMeta).length > 0 ? contextMeta : void 0;
1315
- const breadcrumbs = breadcrumbsRef.current.getAll();
1458
+ const breadcrumbs = breadcrumbsRef.current.getAll().map((c) => ({
1459
+ ...c,
1460
+ message: scrubPii3(c.message)
1461
+ }));
1316
1462
  const timeline = breadcrumbs.map((c) => ({
1317
1463
  ts: c.timestamp,
1318
1464
  kind: breadcrumbToTimelineKind(c.category),
@@ -1323,7 +1469,7 @@ function MushiProvider({ children, config: configProp, ...barePropConfig }) {
1323
1469
  id: newUuid(),
1324
1470
  projectId: config.projectId,
1325
1471
  category: data.category,
1326
- description: data.description,
1472
+ description: scrubPii3(data.description),
1327
1473
  environment: {
1328
1474
  userAgent: deviceInfo.systemName ?? "ReactNative",
1329
1475
  platform: deviceInfo.platform ?? "mobile",
@@ -1334,7 +1480,10 @@ function MushiProvider({ children, config: configProp, ...barePropConfig }) {
1334
1480
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1335
1481
  timezone: deviceInfo.timezone ?? "UTC"
1336
1482
  },
1337
- consoleLogs: consoleRef.current?.getEntries() ?? [],
1483
+ consoleLogs: (consoleRef.current?.getEntries() ?? []).map((entry) => ({
1484
+ ...entry,
1485
+ message: scrubPii3(entry.message)
1486
+ })),
1338
1487
  networkLogs: networkRef.current?.getEntries().map((entry) => ({
1339
1488
  ...entry,
1340
1489
  status: entry.status ?? 0
@@ -1355,6 +1504,14 @@ function MushiProvider({ children, config: configProp, ...barePropConfig }) {
1355
1504
  if (!client) return;
1356
1505
  const result = await client.submitReport(report);
1357
1506
  if (!result.ok) {
1507
+ const isCredentialError = result.error?.code === "HTTP_401" || result.error?.code === "HTTP_403" || result.error?.code?.includes("UNAUTHORIZED") || result.error?.code?.includes("FORBIDDEN");
1508
+ if (isCredentialError) {
1509
+ const where = apiEndpoint.replace(/\/$/, "") === DEFAULT_API_ENDPOINT ? "Get the correct values at: https://kensaur.us/mushi-mushi/admin/projects" : "Get the correct values from your Mushi console's Projects page.";
1510
+ console.error(
1511
+ '[Mushi] Credentials rejected. Check your projectId and apiKey (must have "report:write" scope). ' + where
1512
+ );
1513
+ return;
1514
+ }
1358
1515
  await queueRef.current?.enqueue(report);
1359
1516
  config.callbacks?.onReportQueued?.(report.id);
1360
1517
  } else {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mushi-mushi/react-native",
3
- "version": "0.19.0",
3
+ "version": "0.19.2",
4
4
  "license": "MIT",
5
5
  "description": "React Native SDK for Mushi Mushi — shake-to-report, bottom sheet widget, full context capture",
6
6
  "main": "./dist/index.cjs",
@@ -11,6 +11,7 @@
11
11
  "@react-native-community/netinfo": ">=11",
12
12
  "@react-navigation/native": ">=6",
13
13
  "expo-sensors": ">=13",
14
+ "expo-secure-store": ">=13",
14
15
  "react": ">=18",
15
16
  "react-native": ">=0.72",
16
17
  "react-native-view-shot": ">=3.8"
@@ -25,6 +26,9 @@
25
26
  "expo-sensors": {
26
27
  "optional": true
27
28
  },
29
+ "expo-secure-store": {
30
+ "optional": true
31
+ },
28
32
  "@react-native-community/netinfo": {
29
33
  "optional": true
30
34
  },
@@ -33,7 +37,7 @@
33
37
  }
34
38
  },
35
39
  "dependencies": {
36
- "@mushi-mushi/core": "^1.19.0"
40
+ "@mushi-mushi/core": "^1.19.2"
37
41
  },
38
42
  "devDependencies": {
39
43
  "@types/react": "^19.2.17",