@mushi-mushi/react-native 0.19.0 → 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,18 @@
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
+
3
16
  ## 0.19.0
4
17
 
5
18
  ### 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,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;
@@ -499,7 +634,7 @@ var MushiBottomSheet = ({
499
634
  }
500
635
  };
501
636
  const activeScreenshot = screenshotDataUrl && screenshotAttached ? screenshotDataUrl : null;
502
- const pal = (0, import_core.mushiPalette)(dark ? "dark" : "light");
637
+ const pal = (0, import_core3.mushiPalette)(dark ? "dark" : "light");
503
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 };
504
639
  const canSubmit = !!category && description.trim().length > 0 && phase === "form";
505
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)(
@@ -894,7 +1029,7 @@ var s = import_react_native2.StyleSheet.create({
894
1029
  // src/components/MushiFloatingButton.tsx
895
1030
  var import_react2 = require("react");
896
1031
  var import_react_native3 = require("react-native");
897
- var import_core2 = require("@mushi-mushi/core");
1032
+ var import_core4 = require("@mushi-mushi/core");
898
1033
  var import_jsx_runtime2 = require("react/jsx-runtime");
899
1034
  var FAB_SIZE = 56;
900
1035
  var EDGE_PADDING = 12;
@@ -946,7 +1081,7 @@ var MushiFloatingButton = ({
946
1081
  const currentX = (0, import_react2.useRef)(baseX);
947
1082
  const currentY = (0, import_react2.useRef)(0);
948
1083
  const isDragging = (0, import_react2.useRef)(false);
949
- const accentColor = accent ?? (0, import_core2.mushiPalette)(dark ? "dark" : "light").accent;
1084
+ const accentColor = accent ?? (0, import_core4.mushiPalette)(dark ? "dark" : "light").accent;
950
1085
  (0, import_react2.useEffect)(() => {
951
1086
  if (!persist) return;
952
1087
  void loadStoredPos().then((pos) => {
@@ -1056,7 +1191,7 @@ var MushiFloatingButton = ({
1056
1191
  onStartShouldSetResponder: () => !draggable,
1057
1192
  onResponderGrant: handlePressIn,
1058
1193
  onResponderRelease: handlePressOut,
1059
- 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 })
1060
1195
  }
1061
1196
  );
1062
1197
  };
@@ -1081,7 +1216,7 @@ var s2 = import_react_native3.StyleSheet.create({
1081
1216
 
1082
1217
  // src/version.ts
1083
1218
  var MUSHI_SDK_PACKAGE = "@mushi-mushi/react-native";
1084
- var MUSHI_SDK_VERSION = "0.19.0" ? "0.19.0" : "0.17.0";
1219
+ var MUSHI_SDK_VERSION = "0.19.1" ? "0.19.1" : "0.17.0";
1085
1220
 
1086
1221
  // src/provider.tsx
1087
1222
  var import_jsx_runtime3 = require("react/jsx-runtime");
@@ -1128,9 +1263,9 @@ function MushiProvider({ children, config: configProp, ...barePropConfig }) {
1128
1263
  const networkRef = (0, import_react3.useRef)(null);
1129
1264
  const queueRef = (0, import_react3.useRef)(null);
1130
1265
  const apiClientRef = (0, import_react3.useRef)(null);
1131
- const sessionIdRef = (0, import_react3.useRef)(`sess_${(0, import_core3.newUuid)()}`);
1266
+ const sessionIdRef = (0, import_react3.useRef)(`sess_${(0, import_core5.newUuid)()}`);
1132
1267
  const breadcrumbsRef = (0, import_react3.useRef)(
1133
- (0, import_core3.createBreadcrumbBuffer)({ max: config.capture?.maxBreadcrumbs ?? 50 })
1268
+ (0, import_core5.createBreadcrumbBuffer)({ max: config.capture?.maxBreadcrumbs ?? 50 })
1134
1269
  );
1135
1270
  const [sheetVisible, setSheetVisible] = (0, import_react3.useState)(false);
1136
1271
  const [sheetScreenshot, setSheetScreenshot] = (0, import_react3.useState)(null);
@@ -1153,7 +1288,7 @@ function MushiProvider({ children, config: configProp, ...barePropConfig }) {
1153
1288
  if (config.endpoint && !config.apiEndpoint && __DEV__) {
1154
1289
  console.warn("[MushiProvider] `endpoint` is deprecated \u2014 use `apiEndpoint` instead.");
1155
1290
  }
1156
- const apiEndpoint = rawEndpoint ?? import_core3.DEFAULT_API_ENDPOINT;
1291
+ const apiEndpoint = rawEndpoint ?? import_core5.DEFAULT_API_ENDPOINT;
1157
1292
  (0, import_react3.useEffect)(() => {
1158
1293
  if (config.capture?.console !== false) {
1159
1294
  consoleRef.current = setupConsoleCapture(config.capture?.maxConsoleEntries);
@@ -1161,7 +1296,7 @@ function MushiProvider({ children, config: configProp, ...barePropConfig }) {
1161
1296
  if (config.capture?.network !== false) {
1162
1297
  networkRef.current = setupNetworkCapture(config.capture?.maxNetworkEntries, apiEndpoint);
1163
1298
  }
1164
- apiClientRef.current = (0, import_core3.createApiClient)({
1299
+ apiClientRef.current = (0, import_core5.createApiClient)({
1165
1300
  projectId: config.projectId,
1166
1301
  apiKey: config.apiKey,
1167
1302
  apiEndpoint,
@@ -1173,6 +1308,11 @@ function MushiProvider({ children, config: configProp, ...barePropConfig }) {
1173
1308
  maxSize: config.storage?.maxQueueSize,
1174
1309
  apiEndpoint,
1175
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,
1176
1316
  // `onReportSynced` fires here — when a previously-queued report actually
1177
1317
  // drains to the server — not on the direct-submit path (where it would be
1178
1318
  // indistinguishable from `onReportSubmitted` and thus meaningless).
@@ -1187,21 +1327,20 @@ function MushiProvider({ children, config: configProp, ...barePropConfig }) {
1187
1327
  data: { sdk: MUSHI_SDK_PACKAGE, version: MUSHI_SDK_VERSION }
1188
1328
  });
1189
1329
  (async () => {
1190
- const TOKEN_KEY = "@mushi:reporter_token";
1191
1330
  try {
1192
- const AsyncStorage = (await import("@react-native-async-storage/async-storage")).default;
1193
- const existing = await AsyncStorage.getItem(TOKEN_KEY);
1331
+ const secureStorage = config.storage?.secureStorage !== false;
1332
+ const existing = await loadReporterToken(secureStorage);
1194
1333
  if (existing && existing.startsWith("mushi_")) {
1195
1334
  reporterTokenRef.current = existing;
1196
1335
  } else {
1197
- const fresh = `mushi_${(0, import_core3.newUuid)()}`;
1336
+ const fresh = `mushi_${(0, import_core5.newUuid)()}`;
1198
1337
  reporterTokenRef.current = fresh;
1199
- await AsyncStorage.setItem(TOKEN_KEY, fresh);
1338
+ await saveReporterToken(fresh, secureStorage);
1200
1339
  }
1201
1340
  resolveReporterTokenReady();
1202
1341
  } catch {
1203
1342
  if (reporterTokenRef.current === `rn-${config.projectId}-anon`) {
1204
- reporterTokenRef.current = `mushi_${(0, import_core3.newUuid)()}`;
1343
+ reporterTokenRef.current = `mushi_${(0, import_core5.newUuid)()}`;
1205
1344
  }
1206
1345
  resolveReporterTokenReady();
1207
1346
  }
@@ -1311,7 +1450,10 @@ function MushiProvider({ children, config: configProp, ...barePropConfig }) {
1311
1450
  if (screenRef.current?.route) contextMeta.screenRoute = screenRef.current.route;
1312
1451
  if (screenRef.current?.feature) contextMeta.screenFeature = screenRef.current.feature;
1313
1452
  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();
1453
+ const breadcrumbs = breadcrumbsRef.current.getAll().map((c) => ({
1454
+ ...c,
1455
+ message: (0, import_core5.scrubPii)(c.message)
1456
+ }));
1315
1457
  const timeline = breadcrumbs.map((c) => ({
1316
1458
  ts: c.timestamp,
1317
1459
  kind: breadcrumbToTimelineKind(c.category),
@@ -1319,10 +1461,10 @@ function MushiProvider({ children, config: configProp, ...barePropConfig }) {
1319
1461
  }));
1320
1462
  const fingerprintHash = getDeviceFingerprintHash(deviceInfo);
1321
1463
  const report = {
1322
- id: (0, import_core3.newUuid)(),
1464
+ id: (0, import_core5.newUuid)(),
1323
1465
  projectId: config.projectId,
1324
1466
  category: data.category,
1325
- description: data.description,
1467
+ description: (0, import_core5.scrubPii)(data.description),
1326
1468
  environment: {
1327
1469
  userAgent: deviceInfo.systemName ?? "ReactNative",
1328
1470
  platform: deviceInfo.platform ?? "mobile",
@@ -1333,7 +1475,10 @@ function MushiProvider({ children, config: configProp, ...barePropConfig }) {
1333
1475
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1334
1476
  timezone: deviceInfo.timezone ?? "UTC"
1335
1477
  },
1336
- consoleLogs: consoleRef.current?.getEntries() ?? [],
1478
+ consoleLogs: (consoleRef.current?.getEntries() ?? []).map((entry) => ({
1479
+ ...entry,
1480
+ message: (0, import_core5.scrubPii)(entry.message)
1481
+ })),
1337
1482
  networkLogs: networkRef.current?.getEntries().map((entry) => ({
1338
1483
  ...entry,
1339
1484
  status: entry.status ?? 0
@@ -1354,6 +1499,13 @@ function MushiProvider({ children, config: configProp, ...barePropConfig }) {
1354
1499
  if (!client) return;
1355
1500
  const result = await client.submitReport(report);
1356
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
+ }
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,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
  }
@@ -1082,7 +1222,7 @@ var s2 = StyleSheet2.create({
1082
1222
 
1083
1223
  // src/version.ts
1084
1224
  var MUSHI_SDK_PACKAGE = "@mushi-mushi/react-native";
1085
- var MUSHI_SDK_VERSION = "0.19.0" ? "0.19.0" : "0.17.0";
1225
+ var MUSHI_SDK_VERSION = "0.19.1" ? "0.19.1" : "0.17.0";
1086
1226
 
1087
1227
  // src/provider.tsx
1088
1228
  import { jsx as jsx3, jsxs as jsxs2 } from "react/jsx-runtime";
@@ -1174,6 +1314,11 @@ function MushiProvider({ children, config: configProp, ...barePropConfig }) {
1174
1314
  maxSize: config.storage?.maxQueueSize,
1175
1315
  apiEndpoint,
1176
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,
1177
1322
  // `onReportSynced` fires here — when a previously-queued report actually
1178
1323
  // drains to the server — not on the direct-submit path (where it would be
1179
1324
  // indistinguishable from `onReportSubmitted` and thus meaningless).
@@ -1188,16 +1333,15 @@ function MushiProvider({ children, config: configProp, ...barePropConfig }) {
1188
1333
  data: { sdk: MUSHI_SDK_PACKAGE, version: MUSHI_SDK_VERSION }
1189
1334
  });
1190
1335
  (async () => {
1191
- const TOKEN_KEY = "@mushi:reporter_token";
1192
1336
  try {
1193
- const AsyncStorage = (await import("@react-native-async-storage/async-storage")).default;
1194
- const existing = await AsyncStorage.getItem(TOKEN_KEY);
1337
+ const secureStorage = config.storage?.secureStorage !== false;
1338
+ const existing = await loadReporterToken(secureStorage);
1195
1339
  if (existing && existing.startsWith("mushi_")) {
1196
1340
  reporterTokenRef.current = existing;
1197
1341
  } else {
1198
1342
  const fresh = `mushi_${newUuid()}`;
1199
1343
  reporterTokenRef.current = fresh;
1200
- await AsyncStorage.setItem(TOKEN_KEY, fresh);
1344
+ await saveReporterToken(fresh, secureStorage);
1201
1345
  }
1202
1346
  resolveReporterTokenReady();
1203
1347
  } catch {
@@ -1312,7 +1456,10 @@ function MushiProvider({ children, config: configProp, ...barePropConfig }) {
1312
1456
  if (screenRef.current?.route) contextMeta.screenRoute = screenRef.current.route;
1313
1457
  if (screenRef.current?.feature) contextMeta.screenFeature = screenRef.current.feature;
1314
1458
  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();
1459
+ const breadcrumbs = breadcrumbsRef.current.getAll().map((c) => ({
1460
+ ...c,
1461
+ message: scrubPii3(c.message)
1462
+ }));
1316
1463
  const timeline = breadcrumbs.map((c) => ({
1317
1464
  ts: c.timestamp,
1318
1465
  kind: breadcrumbToTimelineKind(c.category),
@@ -1323,7 +1470,7 @@ function MushiProvider({ children, config: configProp, ...barePropConfig }) {
1323
1470
  id: newUuid(),
1324
1471
  projectId: config.projectId,
1325
1472
  category: data.category,
1326
- description: data.description,
1473
+ description: scrubPii3(data.description),
1327
1474
  environment: {
1328
1475
  userAgent: deviceInfo.systemName ?? "ReactNative",
1329
1476
  platform: deviceInfo.platform ?? "mobile",
@@ -1334,7 +1481,10 @@ function MushiProvider({ children, config: configProp, ...barePropConfig }) {
1334
1481
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1335
1482
  timezone: deviceInfo.timezone ?? "UTC"
1336
1483
  },
1337
- consoleLogs: consoleRef.current?.getEntries() ?? [],
1484
+ consoleLogs: (consoleRef.current?.getEntries() ?? []).map((entry) => ({
1485
+ ...entry,
1486
+ message: scrubPii3(entry.message)
1487
+ })),
1338
1488
  networkLogs: networkRef.current?.getEntries().map((entry) => ({
1339
1489
  ...entry,
1340
1490
  status: entry.status ?? 0
@@ -1355,6 +1505,13 @@ function MushiProvider({ children, config: configProp, ...barePropConfig }) {
1355
1505
  if (!client) return;
1356
1506
  const result = await client.submitReport(report);
1357
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
+ }
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.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.19.0"
40
+ "@mushi-mushi/core": "^1.19.1"
37
41
  },
38
42
  "devDependencies": {
39
43
  "@types/react": "^19.2.17",