@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 +45 -0
- package/CONTRIBUTING.md +12 -0
- package/README.md +16 -2
- package/dist/index.cjs +240 -80
- package/dist/index.d.cts +37 -7
- package/dist/index.d.ts +37 -7
- package/dist/index.js +232 -67
- package/package.json +6 -2
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
|
|
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
|
|
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
|
|
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
|
|
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,
|
|
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
|
|
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
|
-
|
|
269
|
-
|
|
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
|
-
|
|
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(`${
|
|
446
|
+
const res = await fetch(`${ctx.apiEndpoint}/v1/reports`, {
|
|
317
447
|
method: "POST",
|
|
318
|
-
headers: {
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
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,
|
|
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,
|
|
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
|
|
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,
|
|
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
|
|
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,
|
|
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:
|
|
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.
|
|
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,
|
|
1266
|
+
const sessionIdRef = (0, import_react3.useRef)(`sess_${(0, import_core5.newUuid)()}`);
|
|
1125
1267
|
const breadcrumbsRef = (0, import_react3.useRef)(
|
|
1126
|
-
(0,
|
|
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 ??
|
|
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,
|
|
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
|
|
1186
|
-
const existing = await
|
|
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,
|
|
1336
|
+
const fresh = `mushi_${(0, import_core5.newUuid)()}`;
|
|
1191
1337
|
reporterTokenRef.current = fresh;
|
|
1192
|
-
await
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|
|
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 =
|
|
373
|
+
next.description = scrubPii2(next.description);
|
|
229
374
|
}
|
|
230
375
|
if (typeof next.summary === "string") {
|
|
231
|
-
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:
|
|
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
|
-
|
|
243
|
-
|
|
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
|
-
|
|
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(`${
|
|
425
|
+
const res = await fetch(`${ctx.apiEndpoint}/v1/reports`, {
|
|
291
426
|
method: "POST",
|
|
292
|
-
headers: {
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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,
|
|
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,
|
|
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.
|
|
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
|
|
1187
|
-
const existing = await
|
|
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
|
|
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.
|
|
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.
|
|
40
|
+
"@mushi-mushi/core": "^1.19.1"
|
|
37
41
|
},
|
|
38
42
|
"devDependencies": {
|
|
39
43
|
"@types/react": "^19.2.17",
|