@mushi-mushi/react-native 0.18.1 → 0.19.1

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,50 @@
1
1
  # @mushi-mushi/react-native
2
2
 
3
+ ## 0.19.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 08108e6: Setup UX overhaul: zero-paste `mushi login` browser device-auth, credential error visibility, and docs fixes.
8
+
9
+ - **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.
10
+ - **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.
11
+ - **React Native**: Same 401/403 credential-failure detection in `MushiProvider.submitReport` — skips enqueue and surfaces the error immediately.
12
+
13
+ - Updated dependencies [08108e6]
14
+ - @mushi-mushi/core@1.19.1
15
+
16
+ ## 0.19.0
17
+
18
+ ### Minor Changes
19
+
20
+ - a8f183e: Show the captured screenshot as a visible preview with a configurable
21
+ "remove anything sensitive" privacy caption, so reporters can see and consent to
22
+ exactly what gets sent.
23
+
24
+ - **core/types**: new `widget.screenshotSensitiveHint?: boolean | string` config.
25
+ `true` (default) shows the localized caption, a string overrides it verbatim,
26
+ `false` hides the caption (the preview + remove control always remain). Travels
27
+ in the `widget` block of `GET /v1/sdk/config`, so it's settable per-host via the
28
+ SDK and remotely via the Mushi console runtime config.
29
+ - **web/widget**: the details step now renders the attached screenshot as an
30
+ `<img>` preview (previously only a "Screenshot attached ✓" label) with an
31
+ optional privacy caption beneath it. The preview stays in sync through the
32
+ annotate/markup flow and clears when the screenshot is removed. Image `src` and
33
+ caption are HTML-escaped. New `en`/`es`/`ja`/`th` strings.
34
+ - **react-native**: the bottom sheet's existing screenshot thumbnail gains the
35
+ same configurable privacy caption, resolved by the provider from
36
+ `widget.screenshotSensitiveHint`.
37
+
38
+ This lets privacy-sensitive hosts (e.g. finance apps) enable screenshot capture
39
+ with an explicit user-facing review-and-remove step instead of disabling it
40
+ outright.
41
+
42
+ ### Patch Changes
43
+
44
+ - Updated dependencies [a8f183e]
45
+ - Updated dependencies [a8f183e]
46
+ - @mushi-mushi/core@1.19.0
47
+
3
48
  ## 0.18.1
4
49
 
5
50
  ### Patch 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,154 @@ 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
+ if (!secureStorage) return stored;
269
+ const secure = await loadSecureStore();
270
+ if (!secure || !hasWebCrypto()) return stored;
271
+ const key = await getOrCreateQueueKey(secure);
272
+ if (!key) return stored;
273
+ const combined = base64ToBytes(stored.slice(ENCRYPTED_PREFIX.length));
274
+ if (combined.length < 13) return stored;
275
+ const iv = combined.slice(0, 12);
276
+ const cipher = combined.slice(12);
277
+ try {
278
+ const plain = await globalThis.crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, cipher);
279
+ return new TextDecoder().decode(plain);
280
+ } catch {
281
+ return stored;
282
+ }
283
+ }
284
+ async function loadReporterToken(secureStorage) {
285
+ if (secureStorage) {
286
+ const secure = await loadSecureStore();
287
+ if (secure) {
288
+ const fromSecure = await secure.getItemAsync(REPORTER_TOKEN_KEY);
289
+ if (fromSecure?.startsWith("mushi_")) return fromSecure;
290
+ const storage2 = await loadAsyncStorage();
291
+ if (storage2) {
292
+ const legacy = await storage2.getItem(REPORTER_TOKEN_KEY);
293
+ if (legacy?.startsWith("mushi_")) {
294
+ await secure.setItemAsync(REPORTER_TOKEN_KEY, legacy);
295
+ await storage2.removeItem(REPORTER_TOKEN_KEY);
296
+ return legacy;
297
+ }
298
+ }
299
+ return fromSecure;
300
+ }
301
+ }
302
+ const storage = await loadAsyncStorage();
303
+ if (!storage) return null;
304
+ return storage.getItem(REPORTER_TOKEN_KEY);
305
+ }
306
+ async function saveReporterToken(token, secureStorage) {
307
+ if (secureStorage) {
308
+ const secure = await loadSecureStore();
309
+ if (secure) {
310
+ await secure.setItemAsync(REPORTER_TOKEN_KEY, token);
311
+ const storage2 = await loadAsyncStorage();
312
+ if (storage2) await storage2.removeItem(REPORTER_TOKEN_KEY);
313
+ return;
314
+ }
315
+ }
316
+ const storage = await loadAsyncStorage();
317
+ if (storage) await storage.setItem(REPORTER_TOKEN_KEY, token);
318
+ }
319
+
320
+ // src/storage/async-storage-queue.ts
321
+ var QUEUE_KEY = ENCRYPTED_QUEUE_KEY;
193
322
  var AsyncStorageQueue = class {
194
323
  maxSize;
195
324
  apiEndpoint;
196
325
  apiKey;
326
+ projectId;
327
+ sdkPackage;
328
+ sdkVersion;
329
+ getUserToken;
330
+ secureStorage;
197
331
  onSynced;
198
332
  constructor(config) {
199
333
  this.maxSize = config.maxSize ?? 50;
200
334
  this.apiEndpoint = config.apiEndpoint;
201
335
  this.apiKey = config.apiKey;
336
+ this.projectId = config.projectId;
337
+ this.sdkPackage = config.sdkPackage;
338
+ this.sdkVersion = config.sdkVersion;
339
+ this.getUserToken = config.getUserToken;
340
+ this.secureStorage = config.secureStorage !== false;
202
341
  this.onSynced = config.onSynced;
203
342
  }
204
343
  async enqueue(report) {
@@ -208,7 +347,7 @@ var AsyncStorageQueue = class {
208
347
  if (queue.length >= this.maxSize) queue.shift();
209
348
  const scrubbedReport = this.scrubReportPii(report);
210
349
  queue.push({ report: scrubbedReport, enqueuedAt: Date.now() });
211
- await AsyncStorage.setItem(QUEUE_KEY, JSON.stringify(queue));
350
+ await this.persistQueue(AsyncStorage, queue);
212
351
  }
213
352
  async flush() {
214
353
  const AsyncStorage = await this.getAsyncStorage();
@@ -218,7 +357,14 @@ var AsyncStorageQueue = class {
218
357
  let flushed = 0;
219
358
  const remaining = [];
220
359
  for (const item of queue) {
221
- const ok = await sendWithRetry(item, this.apiEndpoint, this.apiKey);
360
+ const ok = await sendWithRetry(item, {
361
+ apiEndpoint: this.apiEndpoint,
362
+ apiKey: this.apiKey,
363
+ projectId: this.projectId,
364
+ sdkPackage: this.sdkPackage,
365
+ sdkVersion: this.sdkVersion,
366
+ getUserToken: this.getUserToken
367
+ });
222
368
  if (ok) {
223
369
  flushed++;
224
370
  const reportId = item.report.id;
@@ -233,7 +379,7 @@ var AsyncStorageQueue = class {
233
379
  break;
234
380
  }
235
381
  }
236
- await AsyncStorage.setItem(QUEUE_KEY, JSON.stringify(remaining));
382
+ await this.persistQueue(AsyncStorage, remaining);
237
383
  return flushed;
238
384
  }
239
385
  async size() {
@@ -242,31 +388,43 @@ var AsyncStorageQueue = class {
242
388
  const queue = await this.getQueue(AsyncStorage);
243
389
  return queue.length;
244
390
  }
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
391
  scrubReportPii(report) {
252
392
  const next = { ...report };
253
393
  if (typeof next.description === "string") {
254
- next.description = scrubPii(next.description);
394
+ next.description = (0, import_core2.scrubPii)(next.description);
255
395
  }
256
396
  if (typeof next.summary === "string") {
257
- next.summary = scrubPii(next.summary);
397
+ next.summary = (0, import_core2.scrubPii)(next.summary);
258
398
  }
259
399
  if (Array.isArray(next.breadcrumbs)) {
260
400
  next.breadcrumbs = next.breadcrumbs.map(
261
- (b) => typeof b?.message === "string" ? { ...b, message: scrubPii(b.message) } : b
401
+ (b) => typeof b?.message === "string" ? { ...b, message: (0, import_core2.scrubPii)(b.message) } : b
402
+ );
403
+ }
404
+ if (Array.isArray(next.consoleLogs)) {
405
+ next.consoleLogs = next.consoleLogs.map(
406
+ (entry) => typeof entry?.message === "string" ? { ...entry, message: (0, import_core2.scrubPii)(entry.message) } : entry
262
407
  );
263
408
  }
264
409
  return next;
265
410
  }
411
+ async persistQueue(storage, queue) {
412
+ const plaintext = JSON.stringify(queue);
413
+ const blob = await encryptQueueBlob(plaintext, this.secureStorage);
414
+ await storage.setItem(QUEUE_KEY, blob);
415
+ }
266
416
  async getQueue(storage) {
267
417
  try {
268
- const raw = await storage.getItem(QUEUE_KEY);
269
- return raw ? JSON.parse(raw) : [];
418
+ let raw = await storage.getItem(QUEUE_KEY);
419
+ if (!raw) {
420
+ raw = await storage.getItem(LEGACY_QUEUE_KEY);
421
+ if (raw) {
422
+ await storage.removeItem(LEGACY_QUEUE_KEY);
423
+ }
424
+ }
425
+ if (!raw) return [];
426
+ const plaintext = await decryptQueueBlob(raw, this.secureStorage);
427
+ return JSON.parse(plaintext);
270
428
  } catch {
271
429
  return [];
272
430
  }
@@ -280,56 +438,33 @@ var AsyncStorageQueue = class {
280
438
  }
281
439
  }
282
440
  };
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) {
441
+ async function sendWithRetry(item, ctx, attempt = 0) {
314
442
  const delay = Math.min(1e3 * Math.pow(2, attempt) + Math.random() * 500, 1e4);
443
+ const internalKind = "report-submit";
444
+ const userToken = ctx.getUserToken?.() ?? null;
315
445
  try {
316
- const res = await fetch(`${endpoint}/v1/reports`, {
446
+ const res = await fetch(`${ctx.apiEndpoint}/v1/reports`, {
317
447
  method: "POST",
318
- headers: {
319
- "Content-Type": "application/json",
320
- "X-Mushi-Api-Key": apiKey
321
- },
322
- body: JSON.stringify(item.report)
448
+ headers: (0, import_core2.buildSdkIngestHeaders)({
449
+ apiKey: ctx.apiKey,
450
+ projectId: ctx.projectId,
451
+ sdkPackage: ctx.sdkPackage,
452
+ sdkVersion: ctx.sdkVersion,
453
+ userToken,
454
+ internalKind
455
+ }),
456
+ body: JSON.stringify(item.report),
457
+ [import_core2.MUSHI_INTERNAL_INIT_MARKER]: internalKind
323
458
  });
324
459
  if ((res.status === 429 || res.status >= 500) && attempt < 3) {
325
460
  await sleep(delay);
326
- return sendWithRetry(item, endpoint, apiKey, attempt + 1);
461
+ return sendWithRetry(item, ctx, attempt + 1);
327
462
  }
328
463
  return res.ok;
329
464
  } catch {
330
465
  if (attempt < 3) {
331
466
  await sleep(delay);
332
- return sendWithRetry(item, endpoint, apiKey, attempt + 1);
467
+ return sendWithRetry(item, ctx, attempt + 1);
333
468
  }
334
469
  return false;
335
470
  }
@@ -341,7 +476,7 @@ function sleep(ms) {
341
476
  // src/components/MushiBottomSheet.tsx
342
477
  var import_react = require("react");
343
478
  var import_react_native2 = require("react-native");
344
- var import_core = require("@mushi-mushi/core");
479
+ var import_core3 = require("@mushi-mushi/core");
345
480
  var import_jsx_runtime = require("react/jsx-runtime");
346
481
  var { height: SCREEN_HEIGHT } = import_react_native2.Dimensions.get("window");
347
482
  var SHEET_HEIGHT = SCREEN_HEIGHT * 0.55;
@@ -364,7 +499,8 @@ var MushiBottomSheet = ({
364
499
  visible,
365
500
  onClose,
366
501
  screenshotDataUrl,
367
- onClearScreenshot
502
+ onClearScreenshot,
503
+ screenshotSensitiveHint
368
504
  }) => {
369
505
  const mushi = useMushiContext();
370
506
  const scheme = (0, import_react_native2.useColorScheme)();
@@ -498,7 +634,7 @@ var MushiBottomSheet = ({
498
634
  }
499
635
  };
500
636
  const activeScreenshot = screenshotDataUrl && screenshotAttached ? screenshotDataUrl : null;
501
- const pal = (0, import_core.mushiPalette)(dark ? "dark" : "light");
637
+ const pal = (0, import_core3.mushiPalette)(dark ? "dark" : "light");
502
638
  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 };
503
639
  const canSubmit = !!category && description.trim().length > 0 && phase === "form";
504
640
  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)(
@@ -656,7 +792,7 @@ var MushiBottomSheet = ({
656
792
  ),
657
793
  /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_react_native2.View, { style: s.screenshotMeta, children: [
658
794
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react_native2.Text, { style: [s.screenshotLabel, { color: colors.text }], children: "Screenshot attached" }),
659
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react_native2.Text, { style: [s.screenshotSub, { color: colors.sub }], children: "Helps the team see the issue" })
795
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react_native2.Text, { style: [s.screenshotSub, { color: colors.sub }], children: screenshotSensitiveHint && screenshotSensitiveHint.trim() ? `\u26A0 ${screenshotSensitiveHint}` : "Helps the team see the issue" })
660
796
  ] }),
661
797
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
662
798
  import_react_native2.TouchableOpacity,
@@ -893,7 +1029,7 @@ var s = import_react_native2.StyleSheet.create({
893
1029
  // src/components/MushiFloatingButton.tsx
894
1030
  var import_react2 = require("react");
895
1031
  var import_react_native3 = require("react-native");
896
- var import_core2 = require("@mushi-mushi/core");
1032
+ var import_core4 = require("@mushi-mushi/core");
897
1033
  var import_jsx_runtime2 = require("react/jsx-runtime");
898
1034
  var FAB_SIZE = 56;
899
1035
  var EDGE_PADDING = 12;
@@ -945,7 +1081,7 @@ var MushiFloatingButton = ({
945
1081
  const currentX = (0, import_react2.useRef)(baseX);
946
1082
  const currentY = (0, import_react2.useRef)(0);
947
1083
  const isDragging = (0, import_react2.useRef)(false);
948
- const accentColor = accent ?? (0, import_core2.mushiPalette)(dark ? "dark" : "light").accent;
1084
+ const accentColor = accent ?? (0, import_core4.mushiPalette)(dark ? "dark" : "light").accent;
949
1085
  (0, import_react2.useEffect)(() => {
950
1086
  if (!persist) return;
951
1087
  void loadStoredPos().then((pos) => {
@@ -1055,7 +1191,7 @@ var MushiFloatingButton = ({
1055
1191
  onStartShouldSetResponder: () => !draggable,
1056
1192
  onResponderGrant: handlePressIn,
1057
1193
  onResponderRelease: handlePressOut,
1058
- children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_react_native3.Animated.Text, { style: s2.emoji, children: import_core2.MUSHI_COPY.triggerText })
1194
+ children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_react_native3.Animated.Text, { style: s2.emoji, children: import_core4.MUSHI_COPY.triggerText })
1059
1195
  }
1060
1196
  );
1061
1197
  };
@@ -1080,11 +1216,17 @@ var s2 = import_react_native3.StyleSheet.create({
1080
1216
 
1081
1217
  // src/version.ts
1082
1218
  var MUSHI_SDK_PACKAGE = "@mushi-mushi/react-native";
1083
- var MUSHI_SDK_VERSION = "0.18.1" ? "0.18.1" : "0.17.0";
1219
+ var MUSHI_SDK_VERSION = "0.19.1" ? "0.19.1" : "0.17.0";
1084
1220
 
1085
1221
  // src/provider.tsx
1086
1222
  var import_jsx_runtime3 = require("react/jsx-runtime");
1087
1223
  var MushiContext = (0, import_react3.createContext)(null);
1224
+ var DEFAULT_SCREENSHOT_HINT = "Check the preview \u2014 remove it if any private info (balances, personal details) is visible.";
1225
+ function resolveScreenshotHint(v) {
1226
+ if (v === false) return null;
1227
+ if (typeof v === "string") return v.trim() ? v : null;
1228
+ return DEFAULT_SCREENSHOT_HINT;
1229
+ }
1088
1230
  function breadcrumbToTimelineKind(category) {
1089
1231
  switch (category) {
1090
1232
  case "navigation":
@@ -1121,9 +1263,9 @@ function MushiProvider({ children, config: configProp, ...barePropConfig }) {
1121
1263
  const networkRef = (0, import_react3.useRef)(null);
1122
1264
  const queueRef = (0, import_react3.useRef)(null);
1123
1265
  const apiClientRef = (0, import_react3.useRef)(null);
1124
- const sessionIdRef = (0, import_react3.useRef)(`sess_${(0, import_core3.newUuid)()}`);
1266
+ const sessionIdRef = (0, import_react3.useRef)(`sess_${(0, import_core5.newUuid)()}`);
1125
1267
  const breadcrumbsRef = (0, import_react3.useRef)(
1126
- (0, import_core3.createBreadcrumbBuffer)({ max: config.capture?.maxBreadcrumbs ?? 50 })
1268
+ (0, import_core5.createBreadcrumbBuffer)({ max: config.capture?.maxBreadcrumbs ?? 50 })
1127
1269
  );
1128
1270
  const [sheetVisible, setSheetVisible] = (0, import_react3.useState)(false);
1129
1271
  const [sheetScreenshot, setSheetScreenshot] = (0, import_react3.useState)(null);
@@ -1146,7 +1288,7 @@ function MushiProvider({ children, config: configProp, ...barePropConfig }) {
1146
1288
  if (config.endpoint && !config.apiEndpoint && __DEV__) {
1147
1289
  console.warn("[MushiProvider] `endpoint` is deprecated \u2014 use `apiEndpoint` instead.");
1148
1290
  }
1149
- const apiEndpoint = rawEndpoint ?? import_core3.DEFAULT_API_ENDPOINT;
1291
+ const apiEndpoint = rawEndpoint ?? import_core5.DEFAULT_API_ENDPOINT;
1150
1292
  (0, import_react3.useEffect)(() => {
1151
1293
  if (config.capture?.console !== false) {
1152
1294
  consoleRef.current = setupConsoleCapture(config.capture?.maxConsoleEntries);
@@ -1154,7 +1296,7 @@ function MushiProvider({ children, config: configProp, ...barePropConfig }) {
1154
1296
  if (config.capture?.network !== false) {
1155
1297
  networkRef.current = setupNetworkCapture(config.capture?.maxNetworkEntries, apiEndpoint);
1156
1298
  }
1157
- apiClientRef.current = (0, import_core3.createApiClient)({
1299
+ apiClientRef.current = (0, import_core5.createApiClient)({
1158
1300
  projectId: config.projectId,
1159
1301
  apiKey: config.apiKey,
1160
1302
  apiEndpoint,
@@ -1166,6 +1308,11 @@ function MushiProvider({ children, config: configProp, ...barePropConfig }) {
1166
1308
  maxSize: config.storage?.maxQueueSize,
1167
1309
  apiEndpoint,
1168
1310
  apiKey: config.apiKey,
1311
+ projectId: config.projectId,
1312
+ sdkPackage: MUSHI_SDK_PACKAGE,
1313
+ sdkVersion: MUSHI_SDK_VERSION,
1314
+ getUserToken: () => userTokenRef.current,
1315
+ secureStorage: config.storage?.secureStorage,
1169
1316
  // `onReportSynced` fires here — when a previously-queued report actually
1170
1317
  // drains to the server — not on the direct-submit path (where it would be
1171
1318
  // indistinguishable from `onReportSubmitted` and thus meaningless).
@@ -1180,21 +1327,20 @@ function MushiProvider({ children, config: configProp, ...barePropConfig }) {
1180
1327
  data: { sdk: MUSHI_SDK_PACKAGE, version: MUSHI_SDK_VERSION }
1181
1328
  });
1182
1329
  (async () => {
1183
- const TOKEN_KEY = "@mushi:reporter_token";
1184
1330
  try {
1185
- const AsyncStorage = (await import("@react-native-async-storage/async-storage")).default;
1186
- const existing = await AsyncStorage.getItem(TOKEN_KEY);
1331
+ const secureStorage = config.storage?.secureStorage !== false;
1332
+ const existing = await loadReporterToken(secureStorage);
1187
1333
  if (existing && existing.startsWith("mushi_")) {
1188
1334
  reporterTokenRef.current = existing;
1189
1335
  } else {
1190
- const fresh = `mushi_${(0, import_core3.newUuid)()}`;
1336
+ const fresh = `mushi_${(0, import_core5.newUuid)()}`;
1191
1337
  reporterTokenRef.current = fresh;
1192
- await AsyncStorage.setItem(TOKEN_KEY, fresh);
1338
+ await saveReporterToken(fresh, secureStorage);
1193
1339
  }
1194
1340
  resolveReporterTokenReady();
1195
1341
  } catch {
1196
1342
  if (reporterTokenRef.current === `rn-${config.projectId}-anon`) {
1197
- reporterTokenRef.current = `mushi_${(0, import_core3.newUuid)()}`;
1343
+ reporterTokenRef.current = `mushi_${(0, import_core5.newUuid)()}`;
1198
1344
  }
1199
1345
  resolveReporterTokenReady();
1200
1346
  }
@@ -1304,7 +1450,10 @@ function MushiProvider({ children, config: configProp, ...barePropConfig }) {
1304
1450
  if (screenRef.current?.route) contextMeta.screenRoute = screenRef.current.route;
1305
1451
  if (screenRef.current?.feature) contextMeta.screenFeature = screenRef.current.feature;
1306
1452
  const merged = Object.keys(metadataRef.current).length > 0 ? { ...contextMeta, ...metadataRef.current } : Object.keys(contextMeta).length > 0 ? contextMeta : void 0;
1307
- const breadcrumbs = breadcrumbsRef.current.getAll();
1453
+ const breadcrumbs = breadcrumbsRef.current.getAll().map((c) => ({
1454
+ ...c,
1455
+ message: (0, import_core5.scrubPii)(c.message)
1456
+ }));
1308
1457
  const timeline = breadcrumbs.map((c) => ({
1309
1458
  ts: c.timestamp,
1310
1459
  kind: breadcrumbToTimelineKind(c.category),
@@ -1312,10 +1461,10 @@ function MushiProvider({ children, config: configProp, ...barePropConfig }) {
1312
1461
  }));
1313
1462
  const fingerprintHash = getDeviceFingerprintHash(deviceInfo);
1314
1463
  const report = {
1315
- id: (0, import_core3.newUuid)(),
1464
+ id: (0, import_core5.newUuid)(),
1316
1465
  projectId: config.projectId,
1317
1466
  category: data.category,
1318
- description: data.description,
1467
+ description: (0, import_core5.scrubPii)(data.description),
1319
1468
  environment: {
1320
1469
  userAgent: deviceInfo.systemName ?? "ReactNative",
1321
1470
  platform: deviceInfo.platform ?? "mobile",
@@ -1326,7 +1475,10 @@ function MushiProvider({ children, config: configProp, ...barePropConfig }) {
1326
1475
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1327
1476
  timezone: deviceInfo.timezone ?? "UTC"
1328
1477
  },
1329
- consoleLogs: consoleRef.current?.getEntries() ?? [],
1478
+ consoleLogs: (consoleRef.current?.getEntries() ?? []).map((entry) => ({
1479
+ ...entry,
1480
+ message: (0, import_core5.scrubPii)(entry.message)
1481
+ })),
1330
1482
  networkLogs: networkRef.current?.getEntries().map((entry) => ({
1331
1483
  ...entry,
1332
1484
  status: entry.status ?? 0
@@ -1347,6 +1499,13 @@ function MushiProvider({ children, config: configProp, ...barePropConfig }) {
1347
1499
  if (!client) return;
1348
1500
  const result = await client.submitReport(report);
1349
1501
  if (!result.ok) {
1502
+ const isCredentialError = result.error?.code === "HTTP_401" || result.error?.code === "HTTP_403" || result.error?.code?.includes("UNAUTHORIZED") || result.error?.code?.includes("FORBIDDEN");
1503
+ if (isCredentialError) {
1504
+ console.error(
1505
+ '[Mushi] Credentials rejected. Check your projectId and apiKey (must have "report:write" scope). Get the correct values at: https://kensaur.us/mushi-mushi/admin/projects'
1506
+ );
1507
+ return;
1508
+ }
1350
1509
  await queueRef.current?.enqueue(report);
1351
1510
  config.callbacks?.onReportQueued?.(report.id);
1352
1511
  } else {
@@ -1409,7 +1568,7 @@ function MushiProvider({ children, config: configProp, ...barePropConfig }) {
1409
1568
  identifyWithToken(token) {
1410
1569
  userTokenRef.current = token && typeof token === "string" ? token : null;
1411
1570
  if (userTokenRef.current) {
1412
- const claims = (0, import_core3.parseIdentityToken)(userTokenRef.current);
1571
+ const claims = (0, import_core5.parseIdentityToken)(userTokenRef.current);
1413
1572
  if (claims?.sub) {
1414
1573
  userRef.current = {
1415
1574
  id: claims.sub,
@@ -1532,7 +1691,8 @@ function MushiProvider({ children, config: configProp, ...barePropConfig }) {
1532
1691
  visible: sheetVisible,
1533
1692
  onClose: close,
1534
1693
  screenshotDataUrl: sheetScreenshot ?? void 0,
1535
- onClearScreenshot: () => setSheetScreenshot(null)
1694
+ onClearScreenshot: () => setSheetScreenshot(null),
1695
+ screenshotSensitiveHint: resolveScreenshotHint(config.widget?.screenshotSensitiveHint)
1536
1696
  }
1537
1697
  )
1538
1698
  ] });
package/dist/index.d.cts CHANGED
@@ -66,6 +66,16 @@ interface MushiRNConfig {
66
66
  left?: number;
67
67
  right?: number;
68
68
  };
69
+ /**
70
+ * Privacy nudge shown beneath the attached-screenshot preview, reminding
71
+ * the reporter to remove anything sensitive (balances, PII) before sending.
72
+ * The sheet always shows the captured image as a preview the user can
73
+ * remove; this only controls the caption.
74
+ * - `true` (default) — show the default caption.
75
+ * - string — show this custom caption.
76
+ * - `false` — hide the caption (preview + remove still show).
77
+ */
78
+ screenshotSensitiveHint?: boolean | string;
69
79
  };
70
80
  capture?: {
71
81
  console?: boolean;
@@ -88,6 +98,8 @@ interface MushiRNConfig {
88
98
  storage?: {
89
99
  maxQueueSize?: number;
90
100
  retryIntervalMs?: number;
101
+ /** Encrypt offline queue + store reporter token in SecureStore when available. Default true. */
102
+ secureStorage?: boolean;
91
103
  };
92
104
  rewards?: MushiRewardsConfig;
93
105
  }
@@ -220,6 +232,11 @@ interface MushiBottomSheetProps {
220
232
  screenshotDataUrl?: string;
221
233
  /** Called when the user removes the attached screenshot. */
222
234
  onClearScreenshot?: () => void;
235
+ /**
236
+ * Privacy caption shown beneath the screenshot preview. `null` hides it.
237
+ * Resolved by the provider from `widget.screenshotSensitiveHint`.
238
+ */
239
+ screenshotSensitiveHint?: string | null;
223
240
  }
224
241
  declare const MushiBottomSheet: FC<MushiBottomSheetProps>;
225
242
 
@@ -283,22 +300,35 @@ declare function useNavigationCapture(navigationRef: {
283
300
  addListener?: (event: string, cb: () => void) => () => void;
284
301
  } | null): () => NavigationEntry[];
285
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
+ }
286
316
  declare class AsyncStorageQueue {
287
317
  private maxSize;
288
318
  private apiEndpoint;
289
319
  private apiKey;
320
+ private projectId;
321
+ private sdkPackage?;
322
+ private sdkVersion?;
323
+ private getUserToken?;
324
+ private secureStorage;
290
325
  private onSynced?;
291
- constructor(config: {
292
- maxSize?: number;
293
- apiEndpoint: string;
294
- apiKey: string;
295
- /** Fired once per queued report that drains successfully to the server. */
296
- onSynced?: (reportId: string) => void;
297
- });
326
+ constructor(config: AsyncStorageQueueConfig);
298
327
  enqueue(report: object): Promise<void>;
299
328
  flush(): Promise<number>;
300
329
  size(): Promise<number>;
301
330
  private scrubReportPii;
331
+ private persistQueue;
302
332
  private getQueue;
303
333
  private getAsyncStorage;
304
334
  }
package/dist/index.d.ts CHANGED
@@ -66,6 +66,16 @@ interface MushiRNConfig {
66
66
  left?: number;
67
67
  right?: number;
68
68
  };
69
+ /**
70
+ * Privacy nudge shown beneath the attached-screenshot preview, reminding
71
+ * the reporter to remove anything sensitive (balances, PII) before sending.
72
+ * The sheet always shows the captured image as a preview the user can
73
+ * remove; this only controls the caption.
74
+ * - `true` (default) — show the default caption.
75
+ * - string — show this custom caption.
76
+ * - `false` — hide the caption (preview + remove still show).
77
+ */
78
+ screenshotSensitiveHint?: boolean | string;
69
79
  };
70
80
  capture?: {
71
81
  console?: boolean;
@@ -88,6 +98,8 @@ interface MushiRNConfig {
88
98
  storage?: {
89
99
  maxQueueSize?: number;
90
100
  retryIntervalMs?: number;
101
+ /** Encrypt offline queue + store reporter token in SecureStore when available. Default true. */
102
+ secureStorage?: boolean;
91
103
  };
92
104
  rewards?: MushiRewardsConfig;
93
105
  }
@@ -220,6 +232,11 @@ interface MushiBottomSheetProps {
220
232
  screenshotDataUrl?: string;
221
233
  /** Called when the user removes the attached screenshot. */
222
234
  onClearScreenshot?: () => void;
235
+ /**
236
+ * Privacy caption shown beneath the screenshot preview. `null` hides it.
237
+ * Resolved by the provider from `widget.screenshotSensitiveHint`.
238
+ */
239
+ screenshotSensitiveHint?: string | null;
223
240
  }
224
241
  declare const MushiBottomSheet: FC<MushiBottomSheetProps>;
225
242
 
@@ -283,22 +300,35 @@ declare function useNavigationCapture(navigationRef: {
283
300
  addListener?: (event: string, cb: () => void) => () => void;
284
301
  } | null): () => NavigationEntry[];
285
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
+ }
286
316
  declare class AsyncStorageQueue {
287
317
  private maxSize;
288
318
  private apiEndpoint;
289
319
  private apiKey;
320
+ private projectId;
321
+ private sdkPackage?;
322
+ private sdkVersion?;
323
+ private getUserToken?;
324
+ private secureStorage;
290
325
  private onSynced?;
291
- constructor(config: {
292
- maxSize?: number;
293
- apiEndpoint: string;
294
- apiKey: string;
295
- /** Fired once per queued report that drains successfully to the server. */
296
- onSynced?: (reportId: string) => void;
297
- });
326
+ constructor(config: AsyncStorageQueueConfig);
298
327
  enqueue(report: object): Promise<void>;
299
328
  flush(): Promise<number>;
300
329
  size(): Promise<number>;
301
330
  private scrubReportPii;
331
+ private persistQueue;
302
332
  private getQueue;
303
333
  private getAsyncStorage;
304
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,158 @@ 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
+ if (!secureStorage) return stored;
248
+ const secure = await loadSecureStore();
249
+ if (!secure || !hasWebCrypto()) return stored;
250
+ const key = await getOrCreateQueueKey(secure);
251
+ if (!key) return stored;
252
+ const combined = base64ToBytes(stored.slice(ENCRYPTED_PREFIX.length));
253
+ if (combined.length < 13) return stored;
254
+ const iv = combined.slice(0, 12);
255
+ const cipher = combined.slice(12);
256
+ try {
257
+ const plain = await globalThis.crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, cipher);
258
+ return new TextDecoder().decode(plain);
259
+ } catch {
260
+ return stored;
261
+ }
262
+ }
263
+ async function loadReporterToken(secureStorage) {
264
+ if (secureStorage) {
265
+ const secure = await loadSecureStore();
266
+ if (secure) {
267
+ const fromSecure = await secure.getItemAsync(REPORTER_TOKEN_KEY);
268
+ if (fromSecure?.startsWith("mushi_")) return fromSecure;
269
+ const storage2 = await loadAsyncStorage();
270
+ if (storage2) {
271
+ const legacy = await storage2.getItem(REPORTER_TOKEN_KEY);
272
+ if (legacy?.startsWith("mushi_")) {
273
+ await secure.setItemAsync(REPORTER_TOKEN_KEY, legacy);
274
+ await storage2.removeItem(REPORTER_TOKEN_KEY);
275
+ return legacy;
276
+ }
277
+ }
278
+ return fromSecure;
279
+ }
280
+ }
281
+ const storage = await loadAsyncStorage();
282
+ if (!storage) return null;
283
+ return storage.getItem(REPORTER_TOKEN_KEY);
284
+ }
285
+ async function saveReporterToken(token, secureStorage) {
286
+ if (secureStorage) {
287
+ const secure = await loadSecureStore();
288
+ if (secure) {
289
+ await secure.setItemAsync(REPORTER_TOKEN_KEY, token);
290
+ const storage2 = await loadAsyncStorage();
291
+ if (storage2) await storage2.removeItem(REPORTER_TOKEN_KEY);
292
+ return;
293
+ }
294
+ }
295
+ const storage = await loadAsyncStorage();
296
+ if (storage) await storage.setItem(REPORTER_TOKEN_KEY, token);
297
+ }
298
+
299
+ // src/storage/async-storage-queue.ts
300
+ var QUEUE_KEY = ENCRYPTED_QUEUE_KEY;
167
301
  var AsyncStorageQueue = class {
168
302
  maxSize;
169
303
  apiEndpoint;
170
304
  apiKey;
305
+ projectId;
306
+ sdkPackage;
307
+ sdkVersion;
308
+ getUserToken;
309
+ secureStorage;
171
310
  onSynced;
172
311
  constructor(config) {
173
312
  this.maxSize = config.maxSize ?? 50;
174
313
  this.apiEndpoint = config.apiEndpoint;
175
314
  this.apiKey = config.apiKey;
315
+ this.projectId = config.projectId;
316
+ this.sdkPackage = config.sdkPackage;
317
+ this.sdkVersion = config.sdkVersion;
318
+ this.getUserToken = config.getUserToken;
319
+ this.secureStorage = config.secureStorage !== false;
176
320
  this.onSynced = config.onSynced;
177
321
  }
178
322
  async enqueue(report) {
@@ -182,7 +326,7 @@ var AsyncStorageQueue = class {
182
326
  if (queue.length >= this.maxSize) queue.shift();
183
327
  const scrubbedReport = this.scrubReportPii(report);
184
328
  queue.push({ report: scrubbedReport, enqueuedAt: Date.now() });
185
- await AsyncStorage.setItem(QUEUE_KEY, JSON.stringify(queue));
329
+ await this.persistQueue(AsyncStorage, queue);
186
330
  }
187
331
  async flush() {
188
332
  const AsyncStorage = await this.getAsyncStorage();
@@ -192,7 +336,14 @@ var AsyncStorageQueue = class {
192
336
  let flushed = 0;
193
337
  const remaining = [];
194
338
  for (const item of queue) {
195
- const ok = await sendWithRetry(item, this.apiEndpoint, this.apiKey);
339
+ const ok = await sendWithRetry(item, {
340
+ apiEndpoint: this.apiEndpoint,
341
+ apiKey: this.apiKey,
342
+ projectId: this.projectId,
343
+ sdkPackage: this.sdkPackage,
344
+ sdkVersion: this.sdkVersion,
345
+ getUserToken: this.getUserToken
346
+ });
196
347
  if (ok) {
197
348
  flushed++;
198
349
  const reportId = item.report.id;
@@ -207,7 +358,7 @@ var AsyncStorageQueue = class {
207
358
  break;
208
359
  }
209
360
  }
210
- await AsyncStorage.setItem(QUEUE_KEY, JSON.stringify(remaining));
361
+ await this.persistQueue(AsyncStorage, remaining);
211
362
  return flushed;
212
363
  }
213
364
  async size() {
@@ -216,31 +367,43 @@ var AsyncStorageQueue = class {
216
367
  const queue = await this.getQueue(AsyncStorage);
217
368
  return queue.length;
218
369
  }
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
370
  scrubReportPii(report) {
226
371
  const next = { ...report };
227
372
  if (typeof next.description === "string") {
228
- next.description = scrubPii(next.description);
373
+ next.description = scrubPii2(next.description);
229
374
  }
230
375
  if (typeof next.summary === "string") {
231
- next.summary = scrubPii(next.summary);
376
+ next.summary = scrubPii2(next.summary);
232
377
  }
233
378
  if (Array.isArray(next.breadcrumbs)) {
234
379
  next.breadcrumbs = next.breadcrumbs.map(
235
- (b) => typeof b?.message === "string" ? { ...b, message: scrubPii(b.message) } : b
380
+ (b) => typeof b?.message === "string" ? { ...b, message: scrubPii2(b.message) } : b
381
+ );
382
+ }
383
+ if (Array.isArray(next.consoleLogs)) {
384
+ next.consoleLogs = next.consoleLogs.map(
385
+ (entry) => typeof entry?.message === "string" ? { ...entry, message: scrubPii2(entry.message) } : entry
236
386
  );
237
387
  }
238
388
  return next;
239
389
  }
390
+ async persistQueue(storage, queue) {
391
+ const plaintext = JSON.stringify(queue);
392
+ const blob = await encryptQueueBlob(plaintext, this.secureStorage);
393
+ await storage.setItem(QUEUE_KEY, blob);
394
+ }
240
395
  async getQueue(storage) {
241
396
  try {
242
- const raw = await storage.getItem(QUEUE_KEY);
243
- return raw ? JSON.parse(raw) : [];
397
+ let raw = await storage.getItem(QUEUE_KEY);
398
+ if (!raw) {
399
+ raw = await storage.getItem(LEGACY_QUEUE_KEY);
400
+ if (raw) {
401
+ await storage.removeItem(LEGACY_QUEUE_KEY);
402
+ }
403
+ }
404
+ if (!raw) return [];
405
+ const plaintext = await decryptQueueBlob(raw, this.secureStorage);
406
+ return JSON.parse(plaintext);
244
407
  } catch {
245
408
  return [];
246
409
  }
@@ -254,56 +417,33 @@ var AsyncStorageQueue = class {
254
417
  }
255
418
  }
256
419
  };
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) {
420
+ async function sendWithRetry(item, ctx, attempt = 0) {
288
421
  const delay = Math.min(1e3 * Math.pow(2, attempt) + Math.random() * 500, 1e4);
422
+ const internalKind = "report-submit";
423
+ const userToken = ctx.getUserToken?.() ?? null;
289
424
  try {
290
- const res = await fetch(`${endpoint}/v1/reports`, {
425
+ const res = await fetch(`${ctx.apiEndpoint}/v1/reports`, {
291
426
  method: "POST",
292
- headers: {
293
- "Content-Type": "application/json",
294
- "X-Mushi-Api-Key": apiKey
295
- },
296
- body: JSON.stringify(item.report)
427
+ headers: buildSdkIngestHeaders({
428
+ apiKey: ctx.apiKey,
429
+ projectId: ctx.projectId,
430
+ sdkPackage: ctx.sdkPackage,
431
+ sdkVersion: ctx.sdkVersion,
432
+ userToken,
433
+ internalKind
434
+ }),
435
+ body: JSON.stringify(item.report),
436
+ [MUSHI_INTERNAL_INIT_MARKER]: internalKind
297
437
  });
298
438
  if ((res.status === 429 || res.status >= 500) && attempt < 3) {
299
439
  await sleep(delay);
300
- return sendWithRetry(item, endpoint, apiKey, attempt + 1);
440
+ return sendWithRetry(item, ctx, attempt + 1);
301
441
  }
302
442
  return res.ok;
303
443
  } catch {
304
444
  if (attempt < 3) {
305
445
  await sleep(delay);
306
- return sendWithRetry(item, endpoint, apiKey, attempt + 1);
446
+ return sendWithRetry(item, ctx, attempt + 1);
307
447
  }
308
448
  return false;
309
449
  }
@@ -358,7 +498,8 @@ var MushiBottomSheet = ({
358
498
  visible,
359
499
  onClose,
360
500
  screenshotDataUrl,
361
- onClearScreenshot
501
+ onClearScreenshot,
502
+ screenshotSensitiveHint
362
503
  }) => {
363
504
  const mushi = useMushiContext();
364
505
  const scheme = useColorScheme();
@@ -650,7 +791,7 @@ var MushiBottomSheet = ({
650
791
  ),
651
792
  /* @__PURE__ */ jsxs(View, { style: s.screenshotMeta, children: [
652
793
  /* @__PURE__ */ jsx(Text, { style: [s.screenshotLabel, { color: colors.text }], children: "Screenshot attached" }),
653
- /* @__PURE__ */ jsx(Text, { style: [s.screenshotSub, { color: colors.sub }], children: "Helps the team see the issue" })
794
+ /* @__PURE__ */ jsx(Text, { style: [s.screenshotSub, { color: colors.sub }], children: screenshotSensitiveHint && screenshotSensitiveHint.trim() ? `\u26A0 ${screenshotSensitiveHint}` : "Helps the team see the issue" })
654
795
  ] }),
655
796
  /* @__PURE__ */ jsx(
656
797
  TouchableOpacity,
@@ -1081,11 +1222,17 @@ var s2 = StyleSheet2.create({
1081
1222
 
1082
1223
  // src/version.ts
1083
1224
  var MUSHI_SDK_PACKAGE = "@mushi-mushi/react-native";
1084
- var MUSHI_SDK_VERSION = "0.18.1" ? "0.18.1" : "0.17.0";
1225
+ var MUSHI_SDK_VERSION = "0.19.1" ? "0.19.1" : "0.17.0";
1085
1226
 
1086
1227
  // src/provider.tsx
1087
1228
  import { jsx as jsx3, jsxs as jsxs2 } from "react/jsx-runtime";
1088
1229
  var MushiContext = createContext(null);
1230
+ var DEFAULT_SCREENSHOT_HINT = "Check the preview \u2014 remove it if any private info (balances, personal details) is visible.";
1231
+ function resolveScreenshotHint(v) {
1232
+ if (v === false) return null;
1233
+ if (typeof v === "string") return v.trim() ? v : null;
1234
+ return DEFAULT_SCREENSHOT_HINT;
1235
+ }
1089
1236
  function breadcrumbToTimelineKind(category) {
1090
1237
  switch (category) {
1091
1238
  case "navigation":
@@ -1167,6 +1314,11 @@ function MushiProvider({ children, config: configProp, ...barePropConfig }) {
1167
1314
  maxSize: config.storage?.maxQueueSize,
1168
1315
  apiEndpoint,
1169
1316
  apiKey: config.apiKey,
1317
+ projectId: config.projectId,
1318
+ sdkPackage: MUSHI_SDK_PACKAGE,
1319
+ sdkVersion: MUSHI_SDK_VERSION,
1320
+ getUserToken: () => userTokenRef.current,
1321
+ secureStorage: config.storage?.secureStorage,
1170
1322
  // `onReportSynced` fires here — when a previously-queued report actually
1171
1323
  // drains to the server — not on the direct-submit path (where it would be
1172
1324
  // indistinguishable from `onReportSubmitted` and thus meaningless).
@@ -1181,16 +1333,15 @@ function MushiProvider({ children, config: configProp, ...barePropConfig }) {
1181
1333
  data: { sdk: MUSHI_SDK_PACKAGE, version: MUSHI_SDK_VERSION }
1182
1334
  });
1183
1335
  (async () => {
1184
- const TOKEN_KEY = "@mushi:reporter_token";
1185
1336
  try {
1186
- const AsyncStorage = (await import("@react-native-async-storage/async-storage")).default;
1187
- const existing = await AsyncStorage.getItem(TOKEN_KEY);
1337
+ const secureStorage = config.storage?.secureStorage !== false;
1338
+ const existing = await loadReporterToken(secureStorage);
1188
1339
  if (existing && existing.startsWith("mushi_")) {
1189
1340
  reporterTokenRef.current = existing;
1190
1341
  } else {
1191
1342
  const fresh = `mushi_${newUuid()}`;
1192
1343
  reporterTokenRef.current = fresh;
1193
- await AsyncStorage.setItem(TOKEN_KEY, fresh);
1344
+ await saveReporterToken(fresh, secureStorage);
1194
1345
  }
1195
1346
  resolveReporterTokenReady();
1196
1347
  } catch {
@@ -1305,7 +1456,10 @@ function MushiProvider({ children, config: configProp, ...barePropConfig }) {
1305
1456
  if (screenRef.current?.route) contextMeta.screenRoute = screenRef.current.route;
1306
1457
  if (screenRef.current?.feature) contextMeta.screenFeature = screenRef.current.feature;
1307
1458
  const merged = Object.keys(metadataRef.current).length > 0 ? { ...contextMeta, ...metadataRef.current } : Object.keys(contextMeta).length > 0 ? contextMeta : void 0;
1308
- const breadcrumbs = breadcrumbsRef.current.getAll();
1459
+ const breadcrumbs = breadcrumbsRef.current.getAll().map((c) => ({
1460
+ ...c,
1461
+ message: scrubPii3(c.message)
1462
+ }));
1309
1463
  const timeline = breadcrumbs.map((c) => ({
1310
1464
  ts: c.timestamp,
1311
1465
  kind: breadcrumbToTimelineKind(c.category),
@@ -1316,7 +1470,7 @@ function MushiProvider({ children, config: configProp, ...barePropConfig }) {
1316
1470
  id: newUuid(),
1317
1471
  projectId: config.projectId,
1318
1472
  category: data.category,
1319
- description: data.description,
1473
+ description: scrubPii3(data.description),
1320
1474
  environment: {
1321
1475
  userAgent: deviceInfo.systemName ?? "ReactNative",
1322
1476
  platform: deviceInfo.platform ?? "mobile",
@@ -1327,7 +1481,10 @@ function MushiProvider({ children, config: configProp, ...barePropConfig }) {
1327
1481
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1328
1482
  timezone: deviceInfo.timezone ?? "UTC"
1329
1483
  },
1330
- consoleLogs: consoleRef.current?.getEntries() ?? [],
1484
+ consoleLogs: (consoleRef.current?.getEntries() ?? []).map((entry) => ({
1485
+ ...entry,
1486
+ message: scrubPii3(entry.message)
1487
+ })),
1331
1488
  networkLogs: networkRef.current?.getEntries().map((entry) => ({
1332
1489
  ...entry,
1333
1490
  status: entry.status ?? 0
@@ -1348,6 +1505,13 @@ function MushiProvider({ children, config: configProp, ...barePropConfig }) {
1348
1505
  if (!client) return;
1349
1506
  const result = await client.submitReport(report);
1350
1507
  if (!result.ok) {
1508
+ const isCredentialError = result.error?.code === "HTTP_401" || result.error?.code === "HTTP_403" || result.error?.code?.includes("UNAUTHORIZED") || result.error?.code?.includes("FORBIDDEN");
1509
+ if (isCredentialError) {
1510
+ console.error(
1511
+ '[Mushi] Credentials rejected. Check your projectId and apiKey (must have "report:write" scope). Get the correct values at: https://kensaur.us/mushi-mushi/admin/projects'
1512
+ );
1513
+ return;
1514
+ }
1351
1515
  await queueRef.current?.enqueue(report);
1352
1516
  config.callbacks?.onReportQueued?.(report.id);
1353
1517
  } else {
@@ -1533,7 +1697,8 @@ function MushiProvider({ children, config: configProp, ...barePropConfig }) {
1533
1697
  visible: sheetVisible,
1534
1698
  onClose: close,
1535
1699
  screenshotDataUrl: sheetScreenshot ?? void 0,
1536
- onClearScreenshot: () => setSheetScreenshot(null)
1700
+ onClearScreenshot: () => setSheetScreenshot(null),
1701
+ screenshotSensitiveHint: resolveScreenshotHint(config.widget?.screenshotSensitiveHint)
1537
1702
  }
1538
1703
  )
1539
1704
  ] });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mushi-mushi/react-native",
3
- "version": "0.18.1",
3
+ "version": "0.19.1",
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.17.0"
40
+ "@mushi-mushi/core": "^1.19.1"
37
41
  },
38
42
  "devDependencies": {
39
43
  "@types/react": "^19.2.17",