@mushi-mushi/react-native 0.8.0 → 0.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/SECURITY.md CHANGED
@@ -19,15 +19,59 @@ If you discover a security vulnerability, please report it responsibly.
19
19
 
20
20
  **Do NOT open a public GitHub issue.**
21
21
 
22
- Instead, email: **kensaurus@gmail.com**
22
+ Use either channel below:
23
+
24
+ 1. **GitHub Private Vulnerability Reporting** — strongly preferred.
25
+ <https://github.com/kensaurus/mushi-mushi/security/advisories/new>
26
+ Routes the report into a private advisory with built-in CVE issuance,
27
+ patch coordination, and contributor-credit workflow.
28
+ 2. **Email** — `kensaurus@gmail.com`, subject prefix `[mushi-security]`.
29
+ PGP welcome but not required.
23
30
 
24
31
  Include:
25
32
  - Description of the vulnerability
26
- - Steps to reproduce
27
- - Impact assessment
33
+ - Steps to reproduce (smallest reproducer wins)
34
+ - Impact assessment (what an attacker gains)
28
35
  - Suggested fix (if any)
36
+ - Whether you want public credit (and how to spell your name)
37
+
38
+ ### Coordinated-disclosure timeline
39
+
40
+ | Day | Action |
41
+ |-----|--------|
42
+ | 0 | Report received |
43
+ | ≤ 2 | Acknowledgment + assigned a tracking ID |
44
+ | ≤ 7 | Triage complete: severity assigned (CVSS 3.1) and target patch date communicated |
45
+ | ≤ 30 | Patch released for critical / high (CVSS ≥ 7.0); ≤ 60 days for medium; best-effort for low |
46
+ | Patch + 7 | Public advisory + CVE published; reporter credited unless they declined |
47
+ | Patch + 90 | Embargo expires regardless; if upstream is unresponsive, the reporter is free to publish |
48
+
49
+ ### Safe harbor
50
+
51
+ Good-faith security research on Mushi Mushi is welcome. If you stay
52
+ within the rules below, we will not pursue legal action, will not ask
53
+ your hosting provider to take you offline, and will publicly credit your
54
+ work:
55
+
56
+ - Test only against your own self-hosted instance, the public demo at
57
+ <https://kensaur.us/mushi-mushi/admin/>, or accounts you own.
58
+ - Do not access, exfiltrate, or modify data belonging to other users.
59
+ - Do not run automated scanning that affects availability for others
60
+ (rate-limit your tooling, exclude `/health`).
61
+ - Disclose privately first (channels above); do not publish before the
62
+ embargo above expires.
63
+ - Do not intentionally exploit a finding to escalate beyond proving it
64
+ exists.
65
+
66
+ If a finding requires touching production data to confirm, **stop and
67
+ ask first** — describe what you'd need to do and we'll spin up a sandbox.
68
+
69
+ ### Hall of fame
29
70
 
30
- We will acknowledge receipt within 48 hours and aim to release a patch within 7 days for critical issues.
71
+ Researchers who report a confirmed vulnerability are credited in the
72
+ release notes for the patched version and added to
73
+ [`docs/SECURITY_HALL_OF_FAME.md`](./docs/SECURITY_HALL_OF_FAME.md) (with
74
+ permission).
31
75
 
32
76
  ## Scope
33
77
 
@@ -48,6 +92,125 @@ We will acknowledge receipt within 48 hours and aim to release a patch within 7
48
92
  - **Rotate API keys** regularly via the admin console
49
93
  - **Enable SSO** for team projects (Enterprise tier)
50
94
  - **Review audit logs** periodically for suspicious activity
95
+ - **Verify SDK integrity** with `npm audit signatures` after install
96
+ - **Set `Content-Security-Policy`** on any page embedding the Mushi widget;
97
+ the widget itself ships with `script-src 'self'` and does not load
98
+ remote scripts.
99
+
100
+ ## Threat model
101
+
102
+ What we treat as in-scope attacker capabilities, and what we don't.
103
+
104
+ | Capability | In scope | Notes |
105
+ |-----------|----------|-------|
106
+ | Unauthenticated network attacker hitting public endpoints | ✅ | Rate-limit + HMAC + replay protection on every webhook endpoint (`packages/server/supabase/functions/_shared/webhook-middleware.ts`). |
107
+ | Authenticated user trying to read another tenant's data | ✅ | Postgres RLS on every `public.*` table; advisor lints reviewed monthly. |
108
+ | Authenticated user trying to escalate to super-admin | ✅ | Role lives in `auth.users.raw_app_meta_data.role`; cannot be self-edited via PostgREST. |
109
+ | Compromised dependency (npm supply-chain attack) | ✅ | 7-day cooldown + provenance + Harden-Runner + pinned SHAs (see "Supply-chain hardening" below). |
110
+ | Stolen API key | ✅ | Per-key scopes (`api_key_has_scope`), revocation via admin console, audit log of every use. |
111
+ | User pasting a Stripe / OpenAI / GitHub PAT into a bug report | ✅ | PII scrubber redacts ~15 vendor token formats client-side before the report leaves the device. Mirrors `packages/core/src/pii-scrubber.ts` across iOS, Android, Flutter, React Native. |
112
+ | Stolen end-user device with the SDK installed | ⚠️ partial | Offline queue is AsyncStorage / Keychain / SharedPreferences — no app-level encryption beyond the OS default. Reports waiting to flush are vulnerable to a forensic attacker. |
113
+ | Compromised Supabase service-role key | ❌ | Treated as a tier-0 incident; would require key rotation and audit-log forensics. Not defendable in software. |
114
+ | Compromise of `kensaurus@gmail.com` | ❌ | Treated as a project-fork event; downstream consumers should pin to the last known-good version and follow the new release channel. |
115
+ | Physical / OS-level attacker on an end-user device | ❌ | Out of scope. |
116
+ | Malicious fork using the Mushi name to ship malware | ❌ (technical) ✅ (legal) | The MIT/BSL grant lets the fork exist; the trademark policy (`TRADEMARK.md`) makes shipping it under the Mushi name an infringement we will pursue. |
117
+
118
+ ## Data handling and PII
119
+
120
+ ### What the SDK collects by default
121
+
122
+ | Field | Scope | PII risk |
123
+ |-------|-------|----------|
124
+ | URL / route the user was on | Always | Low — strip query strings if your routes encode user IDs. |
125
+ | Browser / OS / device | Always | None |
126
+ | Console errors (last 50) | Opt-in via `captureConsole: true` | Medium — can include user data your code logs. |
127
+ | Network failures (URL + status) | Opt-in via `captureNetwork: true` | Medium — query params logged as-is unless you redact in-app. |
128
+ | User id / email / role | Only if you call `setUser()` | High — only set what you need; we do not auto-discover. |
129
+ | Session replay frames | Off by default | High — handled by the masking layer; passwords / cards / opted-out elements never leave the page. |
130
+ | Free-text bug description | Always | Medium — passed through the PII scrubber (see below). |
131
+
132
+ ### What the PII scrubber redacts before send
133
+
134
+ Implemented identically across `@mushi-mushi/core`, the iOS, Android,
135
+ Flutter, and React Native SDKs. Defaults are below — every category can
136
+ be toggled off, but `secretTokens` is on by default and we recommend
137
+ keeping it that way.
138
+
139
+ | Category | Default | Patterns |
140
+ |----------|---------|----------|
141
+ | `ssns` | on | `123-45-6789` |
142
+ | `creditCards` | on | 12–19 digit Luhn-shaped sequences with optional separators |
143
+ | `secretTokens` | on | AWS access key (`AKIA…` / `ASIA…`), AWS secret (`aws_secret_access_key=…`), Stripe (`sk_live_…`, `sk_test_…`, `rk_…`, `pk_…`), Slack (`xox[abpor]-…`), GitHub PAT (`ghp_…`, `github_pat_…`), OpenAI (`sk-…`, `sk-proj-…`), Anthropic (`sk-ant-…`), Google API (`AIza…`), JWT (`eyJ…` 3-segment) |
144
+ | `emails` | on | RFC-5322 lite |
145
+ | `phones` | on | E.164 with optional country code |
146
+ | `ipAddresses` | off | IPv4 (off because internal IPs are usually not PII and noise hurts triage) |
147
+ | `ipv6` | off | Same |
148
+
149
+ The fields scrubbed are:
150
+
151
+ - `description` — primary free-text field of every bug report
152
+ - `summary` — short summary, in the same composer
153
+ - `breadcrumbs[].message` — auto-captured user-action trail (clicks, route changes, console messages)
154
+
155
+ Structured fields you set explicitly (`metadata.userEmail`,
156
+ `metadata.userId`, etc.) are intentionally **not** scrubbed — those are
157
+ opt-in attribution data, and silently rewriting them would break
158
+ support workflows.
159
+
160
+ ### Where data lives
161
+
162
+ - **Reports & telemetry** — Supabase Postgres in the `us-west-1` region.
163
+ - **Session replays** — Supabase Storage, same region. Lifecycle policy
164
+ trims replays older than 30 days unless explicitly retained from the
165
+ admin console.
166
+ - **Inbound webhook bodies** — only a SHA-256 hash + `delivery_id` of
167
+ the body is persisted (`webhook_audit_log`). The full body is
168
+ processed in memory and discarded.
169
+ - **Outbound integrations** (Slack, Jira, …) — Mushi is a sender only;
170
+ the receiving system's retention applies.
171
+
172
+ ### Encryption
173
+
174
+ | Surface | At rest | In transit |
175
+ |---------|---------|------------|
176
+ | Postgres (Supabase) | AES-256 (Supabase default) | TLS 1.2+ |
177
+ | Supabase Storage (replays) | AES-256 | TLS 1.2+ |
178
+ | Edge Function ↔ Postgres | — | TLS via the Supavisor pooler |
179
+ | SDK ↔ ingest endpoint | — | TLS 1.2+ enforced; HSTS preload on `kensaur.us` |
180
+ | Inbound webhooks | — | TLS terminated at CloudFront / Supabase edge |
181
+ | Audit log integrity | append-only by RLS; no in-row signing | — |
182
+
183
+ ### Cryptographic primitives
184
+
185
+ | Use | Algorithm | Implementation |
186
+ |-----|-----------|---------------|
187
+ | Webhook HMAC verification (Sentry, GitHub, Datadog, Honeycomb, Grafana, Bugsnag, Rollbar, Crashlytics) | HMAC-SHA256, constant-time compare | Web Crypto in Deno; `crypto.subtle.timingSafeEqual` analogue |
188
+ | AWS SNS subscription confirmation | RSA-SHA1 / RSA-SHA256 | Deno `crypto.subtle.verify` with the cert from `SigningCertURL` (URL allow-listed to `*.sns.*.amazonaws.com`) |
189
+ | Opsgenie JWT shared-token | HS256 with `aud` claim verification | `jose` (Deno-compatible) |
190
+ | API-key hashing (database) | SHA-256 prefix + bcrypt secret half | `pgcrypto` |
191
+ | Provenance attestations (npm) | Sigstore (Fulcio + Rekor) | `npm publish --provenance` |
192
+
193
+ We deliberately do not roll our own crypto. If you find an algorithm or
194
+ library above that has been deprecated, please file a security advisory.
195
+
196
+ ### Operator security checklist
197
+
198
+ When you provision a new self-hosted Mushi instance:
199
+
200
+ - [ ] Set `auth_leaked_password_protection = true` in Supabase Auth
201
+ (HaveIBeenPwned blocklist; flagged as `auth_leaked_password_protection`
202
+ in the security advisor).
203
+ - [ ] Enable at least two MFA factors in Supabase Auth (`auth_insufficient_mfa_options`).
204
+ - [ ] Rotate the service-role key on day 1, then quarterly.
205
+ - [ ] Restrict Postgres direct connections to your CI / migration runners
206
+ via Supabase network restrictions.
207
+ - [ ] Run `pnpm dlx supabase advisors --project-ref <ref>` after every
208
+ migration; aim for zero ERROR-level findings.
209
+ - [ ] Configure a Supabase log drain to your SIEM if you are subject to
210
+ SOC 2 / ISO 27001.
211
+ - [ ] Set CSP `frame-ancestors` on the host page if you embed the Mushi
212
+ widget (the widget is iframe-friendly but does not enforce
213
+ framing constraints itself).
51
214
 
52
215
  ## Supply-chain hardening (how this package is protected)
53
216
 
package/dist/index.cjs CHANGED
@@ -138,13 +138,28 @@ function stripQueryParams(url) {
138
138
  var import_react_native = require("react-native");
139
139
  function getDeviceInfo() {
140
140
  const { width, height } = import_react_native.Dimensions.get("window");
141
+ const locale = (
142
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
143
+ import_react_native.NativeModules.SettingsManager?.settings?.AppleLocale ?? // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
144
+ import_react_native.NativeModules.I18nManager?.localeIdentifier ?? "en"
145
+ );
146
+ let timezone = "UTC";
147
+ try {
148
+ timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
149
+ } catch {
150
+ }
151
+ const appVersion = import_react_native.NativeModules.RNDeviceInfo?.appVersion ?? import_react_native.NativeModules.RNBuildConfig?.VERSION_NAME ?? String(import_react_native.Platform.Version);
141
152
  return {
142
153
  platform: import_react_native.Platform.OS,
143
154
  osVersion: import_react_native.Platform.Version,
144
155
  screenWidth: width,
145
156
  screenHeight: height,
146
157
  pixelRatio: import_react_native.PixelRatio.get(),
147
- fontScale: import_react_native.PixelRatio.getFontScale()
158
+ fontScale: import_react_native.PixelRatio.getFontScale(),
159
+ systemName: import_react_native.Platform.OS,
160
+ locale,
161
+ timezone,
162
+ appVersion
148
163
  };
149
164
  }
150
165
 
@@ -164,7 +179,8 @@ var AsyncStorageQueue = class {
164
179
  if (!AsyncStorage) return;
165
180
  const queue = await this.getQueue(AsyncStorage);
166
181
  if (queue.length >= this.maxSize) queue.shift();
167
- queue.push({ report, enqueuedAt: Date.now() });
182
+ const scrubbedReport = this.scrubReportPii(report);
183
+ queue.push({ report: scrubbedReport, enqueuedAt: Date.now() });
168
184
  await AsyncStorage.setItem(QUEUE_KEY, JSON.stringify(queue));
169
185
  }
170
186
  async flush() {
@@ -175,21 +191,10 @@ var AsyncStorageQueue = class {
175
191
  let flushed = 0;
176
192
  const remaining = [];
177
193
  for (const item of queue) {
178
- try {
179
- const res = await fetch(`${this.apiEndpoint}/v1/reports`, {
180
- method: "POST",
181
- headers: {
182
- "Content-Type": "application/json",
183
- "X-Mushi-Api-Key": this.apiKey
184
- },
185
- body: JSON.stringify(item.report)
186
- });
187
- if (res.ok) {
188
- flushed++;
189
- } else {
190
- remaining.push(item);
191
- }
192
- } catch {
194
+ const ok = await sendWithRetry(item, this.apiEndpoint, this.apiKey);
195
+ if (ok) {
196
+ flushed++;
197
+ } else {
193
198
  remaining.push(item);
194
199
  break;
195
200
  }
@@ -203,6 +208,27 @@ var AsyncStorageQueue = class {
203
208
  const queue = await this.getQueue(AsyncStorage);
204
209
  return queue.length;
205
210
  }
211
+ // Added: PII scrubbing (Phase 2.4)
212
+ // Scrubs free-text fields where users typically type emails / phones /
213
+ // card numbers when describing a bug. We deliberately do NOT scrub
214
+ // structured fields like `metadata.userEmail` — those are explicitly
215
+ // captured by the host app via `setUser()` and the operator opts in to
216
+ // collecting them. Scrubbing them here would silently break attribution.
217
+ scrubReportPii(report) {
218
+ const next = { ...report };
219
+ if (typeof next.description === "string") {
220
+ next.description = scrubPii(next.description);
221
+ }
222
+ if (typeof next.summary === "string") {
223
+ next.summary = scrubPii(next.summary);
224
+ }
225
+ if (Array.isArray(next.breadcrumbs)) {
226
+ next.breadcrumbs = next.breadcrumbs.map(
227
+ (b) => typeof b?.message === "string" ? { ...b, message: scrubPii(b.message) } : b
228
+ );
229
+ }
230
+ return next;
231
+ }
206
232
  async getQueue(storage) {
207
233
  try {
208
234
  const raw = await storage.getItem(QUEUE_KEY);
@@ -220,6 +246,63 @@ var AsyncStorageQueue = class {
220
246
  }
221
247
  }
222
248
  };
249
+ var SCRUB_PATTERNS = [
250
+ [/\b\d{3}-\d{2}-\d{4}\b/g, "[REDACTED_SSN]"],
251
+ [/\b(?:\d[ -]*){12,18}\d\b/g, "[REDACTED_CC]"],
252
+ [/\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g, "[REDACTED_AWS_KEY]"],
253
+ [
254
+ /(?:aws_secret_access_key|secret_access_key)["'\s:=]+[A-Za-z0-9/+=]{40}\b/gi,
255
+ "aws_secret_access_key=[REDACTED_AWS_SECRET]"
256
+ ],
257
+ [/\b(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{24,}\b/g, "[REDACTED_STRIPE_KEY]"],
258
+ [/\bpk_(?:live|test)_[A-Za-z0-9]{24,}\b/g, "[REDACTED_STRIPE_PK]"],
259
+ [/\bxox[abpor]-[A-Za-z0-9-]{10,}\b/g, "[REDACTED_SLACK_TOKEN]"],
260
+ [/\bghp_[A-Za-z0-9]{36}\b/g, "[REDACTED_GITHUB_PAT]"],
261
+ [/\bgithub_pat_[A-Za-z0-9_]{80,}\b/g, "[REDACTED_GITHUB_PAT]"],
262
+ [/\bsk-(?:proj-)?[A-Za-z0-9_-]{20,}\b/g, "[REDACTED_OPENAI_KEY]"],
263
+ [/\bsk-ant-[A-Za-z0-9_-]{20,}\b/g, "[REDACTED_ANTHROPIC_KEY]"],
264
+ [/\bAIza[0-9A-Za-z_-]{35}\b/g, "[REDACTED_GOOGLE_KEY]"],
265
+ [/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g, "[REDACTED_JWT]"],
266
+ [/\b[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}\b/g, "[REDACTED_EMAIL]"],
267
+ [
268
+ /(?:\+\d{1,3}[\s.\-])?\(?\d{2,4}\)?[\s.\-]\d{3,4}[\s.\-]\d{3,4}\b/g,
269
+ "[REDACTED_PHONE]"
270
+ ]
271
+ ];
272
+ function scrubPii(text) {
273
+ let result = text;
274
+ for (const [regex, replacement] of SCRUB_PATTERNS) {
275
+ result = result.replace(regex, replacement);
276
+ }
277
+ return result;
278
+ }
279
+ async function sendWithRetry(item, endpoint, apiKey, attempt = 0) {
280
+ const delay = Math.min(1e3 * Math.pow(2, attempt) + Math.random() * 500, 1e4);
281
+ try {
282
+ const res = await fetch(`${endpoint}/v1/reports`, {
283
+ method: "POST",
284
+ headers: {
285
+ "Content-Type": "application/json",
286
+ "X-Mushi-Api-Key": apiKey
287
+ },
288
+ body: JSON.stringify(item.report)
289
+ });
290
+ if ((res.status === 429 || res.status >= 500) && attempt < 3) {
291
+ await sleep(delay);
292
+ return sendWithRetry(item, endpoint, apiKey, attempt + 1);
293
+ }
294
+ return res.ok;
295
+ } catch {
296
+ if (attempt < 3) {
297
+ await sleep(delay);
298
+ return sendWithRetry(item, endpoint, apiKey, attempt + 1);
299
+ }
300
+ return false;
301
+ }
302
+ }
303
+ function sleep(ms) {
304
+ return new Promise((resolve) => setTimeout(resolve, ms));
305
+ }
223
306
 
224
307
  // src/components/MushiBottomSheet.tsx
225
308
  var import_react = require("react");
@@ -599,6 +682,11 @@ function MushiProvider({ children, ...config }) {
599
682
  const queueRef = (0, import_react3.useRef)(null);
600
683
  const apiClientRef = (0, import_react3.useRef)(null);
601
684
  const [sheetVisible, setSheetVisible] = (0, import_react3.useState)(false);
685
+ if (config.endpoint !== void 0 && config.endpoint.trim() === "") {
686
+ throw new Error(
687
+ "[MushiProvider] endpoint is set to an empty string. Set endpoint to your Supabase edge function URL, e.g. https://xyz.supabase.co/functions/v1/api, or omit it to use the cloud default."
688
+ );
689
+ }
602
690
  const apiEndpoint = config.endpoint ?? import_core.DEFAULT_API_ENDPOINT;
603
691
  (0, import_react3.useEffect)(() => {
604
692
  if (config.capture?.console !== false) {
@@ -624,6 +712,22 @@ function MushiProvider({ children, ...config }) {
624
712
  networkRef.current?.restore();
625
713
  };
626
714
  }, []);
715
+ (0, import_react3.useEffect)(() => {
716
+ let unsubscribe;
717
+ (async () => {
718
+ try {
719
+ const NetInfo = await new Function("s", "return import(s)")("@react-native-community/netinfo");
720
+ unsubscribe = NetInfo.default.addEventListener((state) => {
721
+ if (state.isConnected && state.isInternetReachable) {
722
+ queueRef.current?.flush().catch(() => {
723
+ });
724
+ }
725
+ });
726
+ } catch {
727
+ }
728
+ })();
729
+ return () => unsubscribe?.();
730
+ }, []);
627
731
  const open = (0, import_react3.useCallback)(() => setSheetVisible(true), []);
628
732
  const close = (0, import_react3.useCallback)(() => setSheetVisible(false), []);
629
733
  const attachTo = (0, import_react3.useCallback)(() => ({ onPress: open }), [open]);
package/dist/index.d.cts CHANGED
@@ -36,6 +36,7 @@ interface DeviceInfo {
36
36
  systemName?: string;
37
37
  locale?: string;
38
38
  timezone?: string;
39
+ appVersion?: string;
39
40
  }
40
41
  declare function getDeviceInfo(): DeviceInfo;
41
42
 
@@ -189,6 +190,7 @@ declare class AsyncStorageQueue {
189
190
  enqueue(report: object): Promise<void>;
190
191
  flush(): Promise<number>;
191
192
  size(): Promise<number>;
193
+ private scrubReportPii;
192
194
  private getQueue;
193
195
  private getAsyncStorage;
194
196
  }
package/dist/index.d.ts CHANGED
@@ -36,6 +36,7 @@ interface DeviceInfo {
36
36
  systemName?: string;
37
37
  locale?: string;
38
38
  timezone?: string;
39
+ appVersion?: string;
39
40
  }
40
41
  declare function getDeviceInfo(): DeviceInfo;
41
42
 
@@ -189,6 +190,7 @@ declare class AsyncStorageQueue {
189
190
  enqueue(report: object): Promise<void>;
190
191
  flush(): Promise<number>;
191
192
  size(): Promise<number>;
193
+ private scrubReportPii;
192
194
  private getQueue;
193
195
  private getAsyncStorage;
194
196
  }
package/dist/index.js CHANGED
@@ -97,16 +97,31 @@ function stripQueryParams(url) {
97
97
  }
98
98
 
99
99
  // src/capture/device-info.ts
100
- import { Platform, Dimensions, PixelRatio } from "react-native";
100
+ import { Platform, Dimensions, PixelRatio, NativeModules } from "react-native";
101
101
  function getDeviceInfo() {
102
102
  const { width, height } = Dimensions.get("window");
103
+ const locale = (
104
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
105
+ NativeModules.SettingsManager?.settings?.AppleLocale ?? // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
106
+ NativeModules.I18nManager?.localeIdentifier ?? "en"
107
+ );
108
+ let timezone = "UTC";
109
+ try {
110
+ timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
111
+ } catch {
112
+ }
113
+ const appVersion = NativeModules.RNDeviceInfo?.appVersion ?? NativeModules.RNBuildConfig?.VERSION_NAME ?? String(Platform.Version);
103
114
  return {
104
115
  platform: Platform.OS,
105
116
  osVersion: Platform.Version,
106
117
  screenWidth: width,
107
118
  screenHeight: height,
108
119
  pixelRatio: PixelRatio.get(),
109
- fontScale: PixelRatio.getFontScale()
120
+ fontScale: PixelRatio.getFontScale(),
121
+ systemName: Platform.OS,
122
+ locale,
123
+ timezone,
124
+ appVersion
110
125
  };
111
126
  }
112
127
 
@@ -126,7 +141,8 @@ var AsyncStorageQueue = class {
126
141
  if (!AsyncStorage) return;
127
142
  const queue = await this.getQueue(AsyncStorage);
128
143
  if (queue.length >= this.maxSize) queue.shift();
129
- queue.push({ report, enqueuedAt: Date.now() });
144
+ const scrubbedReport = this.scrubReportPii(report);
145
+ queue.push({ report: scrubbedReport, enqueuedAt: Date.now() });
130
146
  await AsyncStorage.setItem(QUEUE_KEY, JSON.stringify(queue));
131
147
  }
132
148
  async flush() {
@@ -137,21 +153,10 @@ var AsyncStorageQueue = class {
137
153
  let flushed = 0;
138
154
  const remaining = [];
139
155
  for (const item of queue) {
140
- try {
141
- const res = await fetch(`${this.apiEndpoint}/v1/reports`, {
142
- method: "POST",
143
- headers: {
144
- "Content-Type": "application/json",
145
- "X-Mushi-Api-Key": this.apiKey
146
- },
147
- body: JSON.stringify(item.report)
148
- });
149
- if (res.ok) {
150
- flushed++;
151
- } else {
152
- remaining.push(item);
153
- }
154
- } catch {
156
+ const ok = await sendWithRetry(item, this.apiEndpoint, this.apiKey);
157
+ if (ok) {
158
+ flushed++;
159
+ } else {
155
160
  remaining.push(item);
156
161
  break;
157
162
  }
@@ -165,6 +170,27 @@ var AsyncStorageQueue = class {
165
170
  const queue = await this.getQueue(AsyncStorage);
166
171
  return queue.length;
167
172
  }
173
+ // Added: PII scrubbing (Phase 2.4)
174
+ // Scrubs free-text fields where users typically type emails / phones /
175
+ // card numbers when describing a bug. We deliberately do NOT scrub
176
+ // structured fields like `metadata.userEmail` — those are explicitly
177
+ // captured by the host app via `setUser()` and the operator opts in to
178
+ // collecting them. Scrubbing them here would silently break attribution.
179
+ scrubReportPii(report) {
180
+ const next = { ...report };
181
+ if (typeof next.description === "string") {
182
+ next.description = scrubPii(next.description);
183
+ }
184
+ if (typeof next.summary === "string") {
185
+ next.summary = scrubPii(next.summary);
186
+ }
187
+ if (Array.isArray(next.breadcrumbs)) {
188
+ next.breadcrumbs = next.breadcrumbs.map(
189
+ (b) => typeof b?.message === "string" ? { ...b, message: scrubPii(b.message) } : b
190
+ );
191
+ }
192
+ return next;
193
+ }
168
194
  async getQueue(storage) {
169
195
  try {
170
196
  const raw = await storage.getItem(QUEUE_KEY);
@@ -182,6 +208,63 @@ var AsyncStorageQueue = class {
182
208
  }
183
209
  }
184
210
  };
211
+ var SCRUB_PATTERNS = [
212
+ [/\b\d{3}-\d{2}-\d{4}\b/g, "[REDACTED_SSN]"],
213
+ [/\b(?:\d[ -]*){12,18}\d\b/g, "[REDACTED_CC]"],
214
+ [/\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g, "[REDACTED_AWS_KEY]"],
215
+ [
216
+ /(?:aws_secret_access_key|secret_access_key)["'\s:=]+[A-Za-z0-9/+=]{40}\b/gi,
217
+ "aws_secret_access_key=[REDACTED_AWS_SECRET]"
218
+ ],
219
+ [/\b(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{24,}\b/g, "[REDACTED_STRIPE_KEY]"],
220
+ [/\bpk_(?:live|test)_[A-Za-z0-9]{24,}\b/g, "[REDACTED_STRIPE_PK]"],
221
+ [/\bxox[abpor]-[A-Za-z0-9-]{10,}\b/g, "[REDACTED_SLACK_TOKEN]"],
222
+ [/\bghp_[A-Za-z0-9]{36}\b/g, "[REDACTED_GITHUB_PAT]"],
223
+ [/\bgithub_pat_[A-Za-z0-9_]{80,}\b/g, "[REDACTED_GITHUB_PAT]"],
224
+ [/\bsk-(?:proj-)?[A-Za-z0-9_-]{20,}\b/g, "[REDACTED_OPENAI_KEY]"],
225
+ [/\bsk-ant-[A-Za-z0-9_-]{20,}\b/g, "[REDACTED_ANTHROPIC_KEY]"],
226
+ [/\bAIza[0-9A-Za-z_-]{35}\b/g, "[REDACTED_GOOGLE_KEY]"],
227
+ [/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g, "[REDACTED_JWT]"],
228
+ [/\b[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}\b/g, "[REDACTED_EMAIL]"],
229
+ [
230
+ /(?:\+\d{1,3}[\s.\-])?\(?\d{2,4}\)?[\s.\-]\d{3,4}[\s.\-]\d{3,4}\b/g,
231
+ "[REDACTED_PHONE]"
232
+ ]
233
+ ];
234
+ function scrubPii(text) {
235
+ let result = text;
236
+ for (const [regex, replacement] of SCRUB_PATTERNS) {
237
+ result = result.replace(regex, replacement);
238
+ }
239
+ return result;
240
+ }
241
+ async function sendWithRetry(item, endpoint, apiKey, attempt = 0) {
242
+ const delay = Math.min(1e3 * Math.pow(2, attempt) + Math.random() * 500, 1e4);
243
+ try {
244
+ const res = await fetch(`${endpoint}/v1/reports`, {
245
+ method: "POST",
246
+ headers: {
247
+ "Content-Type": "application/json",
248
+ "X-Mushi-Api-Key": apiKey
249
+ },
250
+ body: JSON.stringify(item.report)
251
+ });
252
+ if ((res.status === 429 || res.status >= 500) && attempt < 3) {
253
+ await sleep(delay);
254
+ return sendWithRetry(item, endpoint, apiKey, attempt + 1);
255
+ }
256
+ return res.ok;
257
+ } catch {
258
+ if (attempt < 3) {
259
+ await sleep(delay);
260
+ return sendWithRetry(item, endpoint, apiKey, attempt + 1);
261
+ }
262
+ return false;
263
+ }
264
+ }
265
+ function sleep(ms) {
266
+ return new Promise((resolve) => setTimeout(resolve, ms));
267
+ }
185
268
 
186
269
  // src/components/MushiBottomSheet.tsx
187
270
  import {
@@ -585,6 +668,11 @@ function MushiProvider({ children, ...config }) {
585
668
  const queueRef = useRef3(null);
586
669
  const apiClientRef = useRef3(null);
587
670
  const [sheetVisible, setSheetVisible] = useState2(false);
671
+ if (config.endpoint !== void 0 && config.endpoint.trim() === "") {
672
+ throw new Error(
673
+ "[MushiProvider] endpoint is set to an empty string. Set endpoint to your Supabase edge function URL, e.g. https://xyz.supabase.co/functions/v1/api, or omit it to use the cloud default."
674
+ );
675
+ }
588
676
  const apiEndpoint = config.endpoint ?? DEFAULT_API_ENDPOINT;
589
677
  useEffect2(() => {
590
678
  if (config.capture?.console !== false) {
@@ -610,6 +698,22 @@ function MushiProvider({ children, ...config }) {
610
698
  networkRef.current?.restore();
611
699
  };
612
700
  }, []);
701
+ useEffect2(() => {
702
+ let unsubscribe;
703
+ (async () => {
704
+ try {
705
+ const NetInfo = await new Function("s", "return import(s)")("@react-native-community/netinfo");
706
+ unsubscribe = NetInfo.default.addEventListener((state) => {
707
+ if (state.isConnected && state.isInternetReachable) {
708
+ queueRef.current?.flush().catch(() => {
709
+ });
710
+ }
711
+ });
712
+ } catch {
713
+ }
714
+ })();
715
+ return () => unsubscribe?.();
716
+ }, []);
613
717
  const open = useCallback3(() => setSheetVisible(true), []);
614
718
  const close = useCallback3(() => setSheetVisible(false), []);
615
719
  const attachTo = useCallback3(() => ({ onPress: open }), [open]);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mushi-mushi/react-native",
3
- "version": "0.8.0",
3
+ "version": "0.8.2",
4
4
  "license": "MIT",
5
5
  "description": "React Native SDK for Mushi Mushi — shake-to-report, bottom sheet widget, full context capture",
6
6
  "main": "./dist/index.js",
@@ -8,6 +8,7 @@
8
8
  "types": "./dist/index.d.ts",
9
9
  "peerDependencies": {
10
10
  "@react-native-async-storage/async-storage": ">=1.19",
11
+ "@react-native-community/netinfo": ">=11",
11
12
  "@react-navigation/native": ">=6",
12
13
  "expo-sensors": ">=13",
13
14
  "react": ">=18",
@@ -22,10 +23,13 @@
22
23
  },
23
24
  "expo-sensors": {
24
25
  "optional": true
26
+ },
27
+ "@react-native-community/netinfo": {
28
+ "optional": true
25
29
  }
26
30
  },
27
31
  "dependencies": {
28
- "@mushi-mushi/core": "^0.8.0"
32
+ "@mushi-mushi/core": "^1.0.0"
29
33
  },
30
34
  "devDependencies": {
31
35
  "@types/react": "^19.1.4",