* feat(feedback): deliver shared diagnostic reports to Chatwoot support inbox
Wire shareReport() to the Chatwoot public client API
(/public/api/v1/inboxes/{inbox_identifier}) so user-shared diagnostic
reports also reach the OpenCode Mobile Feedback inbox.
- New src/lib/chatwoot.ts: dependency-injected, node-testable client —
anonymous contact -> conversation -> message. Ships only the inbox
identifier (EXPO_PUBLIC_CHATWOOT_INBOX_IDENTIFIER); never an
account api_access_token. Contact source_id persisted via
SecureStore for conversation continuity; stale id recreated on 404.
- Delivery is gated on the same telemetry consent flag as
Sentry/PostHog and is best-effort (share sheet never blocks on it).
- Reports are scrubbed before leaving the device: all URLs and every
occurrence of the target host redacted (new redactHostAndUrls in
scrub.ts).
- CI: pass EXPO_PUBLIC_CHATWOOT_INBOX_IDENTIFIER in build and
Play-publish workflows. Deliberately NOT added to the F-Droid
workflow to avoid widening reproducible-build divergence (#86).
- Consent modal copy discloses support-inbox delivery.
Closes #85
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(feedback): close host-leak gaps in support-report scrubbing
Security review findings on the Chatwoot delivery path:
- Log-buffer lines record server hosts without a scheme, which the
URL regex never matches, and crash reports carry no host of their
own — so bare hostnames could reach the support inbox. Track every
host probed this session and redact them all in the support copy.
- Redact bare IPv4 addresses as a catch-all for hosts never parsed.
- Resolve telemetry consent from SecureStore when a report is shared
before startup finished loading it, instead of silently dropping.
- Move redactHostAndUrls tests to scrub.test.ts alongside the module.
Refs #85
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
232 lines
6.0 KiB
TypeScript
232 lines
6.0 KiB
TypeScript
/**
|
|
* First-launch consent modal for Sentry crash reporting.
|
|
*
|
|
* Shows once when the user hasn't yet made a consent decision.
|
|
* Matches the app's existing Settings screen visual conventions.
|
|
*/
|
|
|
|
import { View, Text, TouchableOpacity, StyleSheet, useColorScheme, Modal, Linking } from "react-native"
|
|
import { Ionicons } from "@expo/vector-icons"
|
|
import { PRIVACY_POLICY_URL } from "../lib/links"
|
|
|
|
interface Props {
|
|
visible: boolean
|
|
onAllow: () => void
|
|
onDecline: () => void
|
|
}
|
|
|
|
export function TelemetryConsentModal({ visible, onAllow, onDecline }: Props) {
|
|
const colorScheme = useColorScheme()
|
|
const isDark = colorScheme === "dark"
|
|
|
|
return (
|
|
<Modal visible={visible} transparent animationType="fade" statusBarTranslucent onRequestClose={onDecline}>
|
|
<View style={styles.overlay}>
|
|
<View style={[styles.card, isDark && styles.cardDark]} testID="telemetry-consent-card">
|
|
{/* Icon */}
|
|
<View style={[styles.iconWrap, isDark && styles.iconWrapDark]}>
|
|
<Ionicons name="bug-outline" size={40} color="#3b82f6" />
|
|
</View>
|
|
|
|
{/* Title */}
|
|
<Text style={[styles.title, isDark && styles.textDark]}>Help improve OpenCode?</Text>
|
|
|
|
{/* Body */}
|
|
<Text style={[styles.body, isDark && styles.bodyDark]}>
|
|
Share anonymous crash reports to help us find and fix bugs faster. Diagnostic reports
|
|
you share are also delivered to our support inbox. No code, prompts, or server
|
|
addresses are ever included.
|
|
</Text>
|
|
|
|
{/* Detail bullets */}
|
|
<View style={styles.bullets}>
|
|
<BulletRow icon="checkmark-circle" text="Device model, OS version, app version" isDark={isDark} positive />
|
|
<BulletRow icon="checkmark-circle" text="Stack traces of crashes (no variable values)" isDark={isDark} positive />
|
|
<BulletRow icon="close-circle" text="Your code, prompts, or chat messages" isDark={isDark} positive={false} />
|
|
<BulletRow icon="close-circle" text="Server URLs or authentication tokens" isDark={isDark} positive={false} />
|
|
</View>
|
|
|
|
{/* Privacy policy link */}
|
|
<TouchableOpacity onPress={() => Linking.openURL(PRIVACY_POLICY_URL)}>
|
|
<Text style={styles.privacyLink}>Read our full privacy policy</Text>
|
|
</TouchableOpacity>
|
|
|
|
{/* Actions */}
|
|
<View style={styles.actions}>
|
|
<TouchableOpacity
|
|
style={[styles.btn, styles.btnDecline, isDark && styles.btnDeclineDark]}
|
|
onPress={onDecline}
|
|
accessibilityLabel="No thanks, decline crash reporting"
|
|
testID="telemetry-decline-button"
|
|
>
|
|
<Text style={[styles.btnDeclineText, isDark && styles.btnDeclineTextDark]}>No thanks</Text>
|
|
</TouchableOpacity>
|
|
<TouchableOpacity
|
|
style={[styles.btn, styles.btnAllow]}
|
|
onPress={onAllow}
|
|
accessibilityLabel="Allow anonymous crash reports"
|
|
testID="telemetry-allow-button"
|
|
>
|
|
<Text style={styles.btnAllowText}>Allow</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
|
|
<Text style={[styles.footnote, isDark && styles.footnoteDark]}>
|
|
You can change this at any time in Settings → Privacy.
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
</Modal>
|
|
)
|
|
}
|
|
|
|
function BulletRow({
|
|
icon,
|
|
text,
|
|
isDark,
|
|
positive,
|
|
}: {
|
|
icon: "checkmark-circle" | "close-circle"
|
|
text: string
|
|
isDark: boolean
|
|
positive: boolean
|
|
}) {
|
|
return (
|
|
<View style={styles.bulletRow}>
|
|
<Ionicons
|
|
name={icon}
|
|
size={18}
|
|
color={positive ? "#22c55e" : "#ef4444"}
|
|
style={styles.bulletIcon}
|
|
/>
|
|
<Text style={[styles.bulletText, isDark && styles.bodyDark]}>{text}</Text>
|
|
</View>
|
|
)
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
overlay: {
|
|
flex: 1,
|
|
backgroundColor: "rgba(0,0,0,0.6)",
|
|
justifyContent: "center",
|
|
alignItems: "center",
|
|
padding: 24,
|
|
},
|
|
card: {
|
|
backgroundColor: "#ffffff",
|
|
borderRadius: 20,
|
|
padding: 28,
|
|
width: "100%",
|
|
maxWidth: 440,
|
|
shadowColor: "#000",
|
|
shadowOffset: { width: 0, height: 8 },
|
|
shadowOpacity: 0.24,
|
|
shadowRadius: 16,
|
|
elevation: 10,
|
|
},
|
|
cardDark: {
|
|
backgroundColor: "#1a1a1a",
|
|
},
|
|
iconWrap: {
|
|
width: 72,
|
|
height: 72,
|
|
borderRadius: 18,
|
|
backgroundColor: "#eff6ff",
|
|
justifyContent: "center",
|
|
alignItems: "center",
|
|
alignSelf: "center",
|
|
marginBottom: 16,
|
|
},
|
|
iconWrapDark: {
|
|
backgroundColor: "#1e293b",
|
|
},
|
|
title: {
|
|
fontSize: 22,
|
|
fontWeight: "700",
|
|
color: "#0a0a0a",
|
|
textAlign: "center",
|
|
marginBottom: 12,
|
|
},
|
|
textDark: {
|
|
color: "#f8fafc",
|
|
},
|
|
body: {
|
|
fontSize: 15,
|
|
color: "#374151",
|
|
textAlign: "center",
|
|
lineHeight: 22,
|
|
marginBottom: 20,
|
|
},
|
|
bodyDark: {
|
|
color: "#94a3b8",
|
|
},
|
|
bullets: {
|
|
marginBottom: 16,
|
|
},
|
|
bulletRow: {
|
|
flexDirection: "row",
|
|
alignItems: "flex-start",
|
|
marginBottom: 8,
|
|
},
|
|
bulletIcon: {
|
|
marginTop: 2,
|
|
marginRight: 10,
|
|
flexShrink: 0,
|
|
},
|
|
bulletText: {
|
|
fontSize: 14,
|
|
color: "#374151",
|
|
flex: 1,
|
|
lineHeight: 20,
|
|
},
|
|
privacyLink: {
|
|
color: "#3b82f6",
|
|
fontSize: 14,
|
|
textAlign: "center",
|
|
marginBottom: 24,
|
|
textDecorationLine: "underline",
|
|
},
|
|
actions: {
|
|
flexDirection: "row",
|
|
gap: 12,
|
|
marginBottom: 16,
|
|
},
|
|
btn: {
|
|
flex: 1,
|
|
paddingVertical: 14,
|
|
borderRadius: 12,
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
},
|
|
btnDecline: {
|
|
backgroundColor: "#f1f5f9",
|
|
},
|
|
btnDeclineDark: {
|
|
backgroundColor: "#2a2a2a",
|
|
},
|
|
btnDeclineText: {
|
|
color: "#374151",
|
|
fontWeight: "600",
|
|
fontSize: 16,
|
|
},
|
|
btnDeclineTextDark: {
|
|
color: "#94a3b8",
|
|
},
|
|
btnAllow: {
|
|
backgroundColor: "#3b82f6",
|
|
},
|
|
btnAllowText: {
|
|
color: "#ffffff",
|
|
fontWeight: "700",
|
|
fontSize: 16,
|
|
},
|
|
footnote: {
|
|
fontSize: 12,
|
|
color: "#9ca3af",
|
|
textAlign: "center",
|
|
},
|
|
footnoteDark: {
|
|
color: "#64748b",
|
|
},
|
|
})
|