feat(feedback): deliver shared diagnostic reports to Chatwoot support inbox (#88)
* 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>
This commit is contained in:
131
src/lib/chatwoot.test.ts
Normal file
131
src/lib/chatwoot.test.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { test, beforeEach, afterEach } from "node:test"
|
||||
import assert from "node:assert/strict"
|
||||
import { chatwootConfigured, sendSupportReport } from "./chatwoot.ts"
|
||||
|
||||
const INBOX = "inbox-token-abc"
|
||||
const BASE = "https://support.example.com"
|
||||
|
||||
const savedEnv = { ...process.env }
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.EXPO_PUBLIC_CHATWOOT_INBOX_IDENTIFIER = INBOX
|
||||
process.env.EXPO_PUBLIC_CHATWOOT_BASE_URL = BASE
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...savedEnv }
|
||||
})
|
||||
|
||||
interface Call {
|
||||
url: string
|
||||
body: Record<string, unknown>
|
||||
}
|
||||
|
||||
// Sequenced fetch mock: each entry answers one call, in order.
|
||||
function mockFetch(responses: Array<{ status: number; json?: unknown }>) {
|
||||
const calls: Call[] = []
|
||||
const fetchFn = (async (url: RequestInfo | URL, init?: RequestInit) => {
|
||||
calls.push({ url: String(url), body: JSON.parse(String(init?.body ?? "{}")) })
|
||||
const next = responses[calls.length - 1] ?? { status: 500 }
|
||||
return {
|
||||
status: next.status,
|
||||
json: async () => {
|
||||
if (next.json === undefined) throw new Error("no body")
|
||||
return next.json
|
||||
},
|
||||
} as Response
|
||||
}) as typeof fetch
|
||||
return { fetchFn, calls }
|
||||
}
|
||||
|
||||
test("chatwootConfigured false without inbox identifier", () => {
|
||||
delete process.env.EXPO_PUBLIC_CHATWOOT_INBOX_IDENTIFIER
|
||||
assert.equal(chatwootConfigured(), false)
|
||||
})
|
||||
|
||||
test("sendSupportReport returns false when unconfigured, makes no calls", async () => {
|
||||
delete process.env.EXPO_PUBLIC_CHATWOOT_INBOX_IDENTIFIER
|
||||
const { fetchFn, calls } = mockFetch([])
|
||||
assert.equal(await sendSupportReport("hello", { fetchFn }), false)
|
||||
assert.equal(calls.length, 0)
|
||||
})
|
||||
|
||||
test("happy path: contact -> conversation -> message, source_id saved", async () => {
|
||||
const { fetchFn, calls } = mockFetch([
|
||||
{ status: 200, json: { source_id: "src-1" } },
|
||||
{ status: 200, json: { id: 42 } },
|
||||
{ status: 200, json: {} },
|
||||
])
|
||||
let saved: string | null = null
|
||||
const ok = await sendSupportReport("report body", {
|
||||
fetchFn,
|
||||
loadSourceId: async () => null,
|
||||
saveSourceId: async (id) => {
|
||||
saved = id
|
||||
},
|
||||
})
|
||||
assert.equal(ok, true)
|
||||
assert.equal(saved, "src-1")
|
||||
assert.deepEqual(
|
||||
calls.map((c) => c.url),
|
||||
[
|
||||
`${BASE}/public/api/v1/inboxes/${INBOX}/contacts`,
|
||||
`${BASE}/public/api/v1/inboxes/${INBOX}/contacts/src-1/conversations`,
|
||||
`${BASE}/public/api/v1/inboxes/${INBOX}/contacts/src-1/conversations/42/messages`,
|
||||
],
|
||||
)
|
||||
assert.equal(calls[2].body.content, "report body")
|
||||
})
|
||||
|
||||
test("stored source_id skips contact creation", async () => {
|
||||
const { fetchFn, calls } = mockFetch([
|
||||
{ status: 200, json: { id: 7 } },
|
||||
{ status: 200, json: {} },
|
||||
])
|
||||
const ok = await sendSupportReport("hi", { fetchFn, loadSourceId: async () => "src-old" })
|
||||
assert.equal(ok, true)
|
||||
assert.equal(calls.length, 2)
|
||||
assert.match(calls[0].url, /contacts\/src-old\/conversations$/)
|
||||
})
|
||||
|
||||
test("stale source_id (404) recreates contact once and retries", async () => {
|
||||
const { fetchFn, calls } = mockFetch([
|
||||
{ status: 404, json: { error: "resource could not be found" } },
|
||||
{ status: 200, json: { source_id: "src-new" } },
|
||||
{ status: 200, json: { id: 9 } },
|
||||
{ status: 200, json: {} },
|
||||
])
|
||||
let saved: string | null = null
|
||||
const ok = await sendSupportReport("hi", {
|
||||
fetchFn,
|
||||
loadSourceId: async () => "src-stale",
|
||||
saveSourceId: async (id) => {
|
||||
saved = id
|
||||
},
|
||||
})
|
||||
assert.equal(ok, true)
|
||||
assert.equal(saved, "src-new")
|
||||
assert.equal(calls.length, 4)
|
||||
assert.match(calls[3].url, /contacts\/src-new\/conversations\/9\/messages$/)
|
||||
})
|
||||
|
||||
test("contact create failure throws", async () => {
|
||||
const { fetchFn } = mockFetch([{ status: 500, json: { error: "boom" } }])
|
||||
await assert.rejects(() => sendSupportReport("hi", { fetchFn, loadSourceId: async () => null }), /http 500/)
|
||||
})
|
||||
|
||||
test("non-404 conversation failure does not recreate contact", async () => {
|
||||
const { fetchFn, calls } = mockFetch([{ status: 500 }])
|
||||
await assert.rejects(() => sendSupportReport("hi", { fetchFn, loadSourceId: async () => "src-x" }), /http 500/)
|
||||
assert.equal(calls.length, 1)
|
||||
})
|
||||
|
||||
test("base url trailing slash is normalised", async () => {
|
||||
process.env.EXPO_PUBLIC_CHATWOOT_BASE_URL = `${BASE}///`
|
||||
const { fetchFn, calls } = mockFetch([
|
||||
{ status: 200, json: { id: 1 } },
|
||||
{ status: 200, json: {} },
|
||||
])
|
||||
await sendSupportReport("hi", { fetchFn, loadSourceId: async () => "s" })
|
||||
assert.equal(calls[0].url, `${BASE}/public/api/v1/inboxes/${INBOX}/contacts/s/conversations`)
|
||||
})
|
||||
143
src/lib/chatwoot.ts
Normal file
143
src/lib/chatwoot.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
// Chatwoot support-feedback client using the PUBLIC client API
|
||||
// (/public/api/v1/inboxes/{inbox_identifier}/...), which is designed for
|
||||
// untrusted clients: the only value shipped in the binary is the inbox
|
||||
// identifier. Never put an account-level api_access_token in this app —
|
||||
// that token can read/write the whole support account.
|
||||
//
|
||||
// Contacts are created anonymously (no identifier), so Chatwoot's HMAC
|
||||
// identifier-validation does not apply. The returned contact source_id is
|
||||
// persisted via injected storage hooks so repeat reports from the same
|
||||
// install land on the same contact.
|
||||
//
|
||||
// This module is dependency-injected and imports no React Native / Expo
|
||||
// packages so it runs under `node --test`.
|
||||
|
||||
const REQUEST_TIMEOUT_MS = 15_000
|
||||
|
||||
export interface ChatwootDeps {
|
||||
fetchFn?: typeof fetch
|
||||
loadSourceId?: () => Promise<string | null>
|
||||
saveSourceId?: (id: string) => Promise<void>
|
||||
}
|
||||
|
||||
interface ChatwootConfig {
|
||||
baseUrl: string
|
||||
inboxIdentifier: string
|
||||
}
|
||||
|
||||
export function getChatwootConfig(): ChatwootConfig | null {
|
||||
const inboxIdentifier = process.env.EXPO_PUBLIC_CHATWOOT_INBOX_IDENTIFIER
|
||||
if (!inboxIdentifier) return null
|
||||
const baseUrl = (process.env.EXPO_PUBLIC_CHATWOOT_BASE_URL || "https://support.agentlabs.cc").replace(/\/+$/, "")
|
||||
return { baseUrl, inboxIdentifier }
|
||||
}
|
||||
|
||||
export function chatwootConfigured(): boolean {
|
||||
return getChatwootConfig() !== null
|
||||
}
|
||||
|
||||
async function post(
|
||||
fetchFn: typeof fetch,
|
||||
url: string,
|
||||
body: Record<string, unknown>,
|
||||
): Promise<{ status: number; json: Record<string, unknown> | null }> {
|
||||
const controller = new AbortController()
|
||||
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS)
|
||||
try {
|
||||
const res = await fetchFn(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
signal: controller.signal,
|
||||
})
|
||||
let json: Record<string, unknown> | null = null
|
||||
try {
|
||||
json = (await res.json()) as Record<string, unknown>
|
||||
} catch {
|
||||
// non-JSON error body
|
||||
}
|
||||
return { status: res.status, json }
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
|
||||
async function createContact(fetchFn: typeof fetch, cfg: ChatwootConfig): Promise<string> {
|
||||
const { status, json } = await post(fetchFn, `${cfg.baseUrl}/public/api/v1/inboxes/${cfg.inboxIdentifier}/contacts`, {
|
||||
name: "OpenCode Mobile user",
|
||||
})
|
||||
const sourceId = json?.source_id
|
||||
if (status >= 400 || typeof sourceId !== "string" || !sourceId) {
|
||||
throw new Error(`chatwoot contact create failed (http ${status})`)
|
||||
}
|
||||
return sourceId
|
||||
}
|
||||
|
||||
async function createConversation(fetchFn: typeof fetch, cfg: ChatwootConfig, sourceId: string): Promise<number> {
|
||||
const { status, json } = await post(
|
||||
fetchFn,
|
||||
`${cfg.baseUrl}/public/api/v1/inboxes/${cfg.inboxIdentifier}/contacts/${encodeURIComponent(sourceId)}/conversations`,
|
||||
{},
|
||||
)
|
||||
const id = json?.id
|
||||
if (status >= 400 || typeof id !== "number") {
|
||||
const err = new Error(`chatwoot conversation create failed (http ${status})`)
|
||||
;(err as Error & { status?: number }).status = status
|
||||
throw err
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
async function postMessage(
|
||||
fetchFn: typeof fetch,
|
||||
cfg: ChatwootConfig,
|
||||
sourceId: string,
|
||||
conversationId: number,
|
||||
content: string,
|
||||
): Promise<void> {
|
||||
const { status } = await post(
|
||||
fetchFn,
|
||||
`${cfg.baseUrl}/public/api/v1/inboxes/${cfg.inboxIdentifier}/contacts/${encodeURIComponent(sourceId)}/conversations/${conversationId}/messages`,
|
||||
{ content },
|
||||
)
|
||||
if (status >= 400) throw new Error(`chatwoot message post failed (http ${status})`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an already-scrubbed report to the support inbox as a new conversation.
|
||||
* Returns true on success, false when Chatwoot is not configured.
|
||||
* Throws on network/API failure — callers decide whether that is fatal
|
||||
* (the in-app share path treats it as best-effort).
|
||||
*
|
||||
* Consent is NOT checked here; this module has no access to the consent
|
||||
* store. Callers MUST gate on telemetry consent before invoking.
|
||||
*/
|
||||
export async function sendSupportReport(content: string, deps: ChatwootDeps = {}): Promise<boolean> {
|
||||
const cfg = getChatwootConfig()
|
||||
if (!cfg) return false
|
||||
const fetchFn = deps.fetchFn ?? fetch
|
||||
|
||||
let sourceId = (await deps.loadSourceId?.().catch(() => null)) ?? null
|
||||
let created = false
|
||||
if (!sourceId) {
|
||||
sourceId = await createContact(fetchFn, cfg)
|
||||
created = true
|
||||
}
|
||||
|
||||
let conversationId: number
|
||||
try {
|
||||
conversationId = await createConversation(fetchFn, cfg, sourceId)
|
||||
} catch (error) {
|
||||
const status = (error as { status?: number }).status
|
||||
// A persisted source_id can go stale (contact deleted server-side).
|
||||
// Recreate the contact once and retry.
|
||||
if (created || status !== 404) throw error
|
||||
sourceId = await createContact(fetchFn, cfg)
|
||||
created = true
|
||||
conversationId = await createConversation(fetchFn, cfg, sourceId)
|
||||
}
|
||||
|
||||
await postMessage(fetchFn, cfg, sourceId, conversationId, content)
|
||||
if (created) await deps.saveSourceId?.(sourceId).catch(() => undefined)
|
||||
return true
|
||||
}
|
||||
@@ -4,9 +4,13 @@
|
||||
import { Platform, Share } from "react-native"
|
||||
import * as Clipboard from "expo-clipboard"
|
||||
import * as Device from "expo-device"
|
||||
import * as SecureStore from "expo-secure-store"
|
||||
import appJson from "../../app.json"
|
||||
import { log, formatLogLines } from "./logbuffer"
|
||||
import { type Classification, type ProbeAttempt, type ParsedUrl, parseUrl, classify } from "./diagnostics-classify"
|
||||
import { chatwootConfigured, sendSupportReport } from "./chatwoot"
|
||||
import { hasTelemetryConsent, loadTelemetryConsent } from "./telemetry"
|
||||
import { redactHostAndUrls } from "./scrub"
|
||||
|
||||
export type { Classification, ProbeAttempt } from "./diagnostics-classify"
|
||||
|
||||
@@ -55,8 +59,14 @@ async function timedFetch(name: string, target: string, init?: RequestInit): Pro
|
||||
}
|
||||
}
|
||||
|
||||
// Every host probed this session. Log lines mention hosts without a scheme
|
||||
// (so URL-based scrubbing misses them), and a crash report has no host of its
|
||||
// own — this set lets formatReportForSupport redact them all regardless.
|
||||
const seenHosts = new Set<string>()
|
||||
|
||||
export async function probeConnection(url: string, auth?: { username: string; password: string }): Promise<DiagnosticReport> {
|
||||
const parsed = parseUrl(url)
|
||||
if (parsed.host) seenHosts.add(parsed.host)
|
||||
log.info("diag", "probe start", url, "parsed", JSON.stringify(parsed))
|
||||
|
||||
const headers: Record<string, string> = {}
|
||||
@@ -160,10 +170,42 @@ export function buildCrashReport(error: unknown, source: "react-boundary" | "glo
|
||||
}
|
||||
}
|
||||
|
||||
// Variant of formatReport for sending off-device: the user's server address
|
||||
// must never leave the phone, so every URL and every occurrence of the target
|
||||
// host is redacted. Classification, probe outcomes, device info and (URL-
|
||||
// scrubbed) logs survive — that is what support needs.
|
||||
export function formatReportForSupport(report: DiagnosticReport): string {
|
||||
return redactHostAndUrls(formatReport(report), [report.host, ...seenHosts])
|
||||
}
|
||||
|
||||
const CHATWOOT_SOURCE_KEY = "opencode_chatwoot_source_id"
|
||||
|
||||
// Best-effort delivery of the scrubbed report to the Chatwoot support inbox.
|
||||
// Only runs when the user has granted telemetry consent (same flag that
|
||||
// gates Sentry/analytics) and the inbox is configured for this build.
|
||||
async function sendReportToSupport(report: DiagnosticReport): Promise<void> {
|
||||
// Consent may not be loaded yet if a crash happens very early in startup —
|
||||
// resolve it from the store rather than silently dropping the report.
|
||||
if (hasTelemetryConsent() === null) await loadTelemetryConsent()
|
||||
if (hasTelemetryConsent() !== true || !chatwootConfigured()) return
|
||||
try {
|
||||
await sendSupportReport(formatReportForSupport(report), {
|
||||
loadSourceId: () => SecureStore.getItemAsync(CHATWOOT_SOURCE_KEY),
|
||||
saveSourceId: (id) => SecureStore.setItemAsync(CHATWOOT_SOURCE_KEY, id),
|
||||
})
|
||||
log.info("diag", "report delivered to support inbox")
|
||||
} catch (e) {
|
||||
log.warn("diag", "support delivery failed", String(e))
|
||||
}
|
||||
}
|
||||
|
||||
// Copy the report to the clipboard and open the native share sheet.
|
||||
// Works fully offline (unlike the Sentry auto-upload).
|
||||
// Works fully offline (unlike the Sentry auto-upload). When telemetry
|
||||
// consent is granted, a scrubbed copy is also delivered to the support
|
||||
// inbox so reports reach us even if the user cancels the share sheet.
|
||||
export async function shareReport(report: DiagnosticReport): Promise<void> {
|
||||
const text = formatReport(report)
|
||||
void sendReportToSupport(report)
|
||||
try {
|
||||
await Clipboard.setStringAsync(text)
|
||||
} catch {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { test } from "node:test"
|
||||
import assert from "node:assert/strict"
|
||||
import { scrubUrl, scrubString, scrubObject } from "./scrub.ts"
|
||||
import { scrubUrl, scrubString, scrubObject, redactHostAndUrls } from "./scrub.ts"
|
||||
|
||||
// scrubUrl ----------------------------------------------------------------
|
||||
|
||||
@@ -103,3 +103,36 @@ test("scrubObject: preserves non-string, non-object values as-is", () => {
|
||||
assert.deepEqual(result.items, [1, 2, 3])
|
||||
assert.equal(result.nothing, null)
|
||||
})
|
||||
|
||||
// redactHostAndUrls -------------------------------------------------------
|
||||
|
||||
test("redactHostAndUrls: strips URLs, bare host occurrences, credentials", () => {
|
||||
const host = "my-dev-box.tail1234.ts.net"
|
||||
const text = [
|
||||
`Target URL: https://user:pw@${host}:4096/api`,
|
||||
` scheme=https host=${host} port=4096 hostname=true`,
|
||||
`probe start http://${host}:4096/global/health`,
|
||||
"internet https://www.gstatic.com/generate_204 OK",
|
||||
].join("\n")
|
||||
const out = redactHostAndUrls(text, [host])
|
||||
assert.ok(!out.includes(host), "host must not survive")
|
||||
assert.ok(!out.includes("user:pw"), "credentials must not survive")
|
||||
assert.ok(out.includes("<redacted-url>"))
|
||||
assert.ok(out.includes("host=<redacted-host>"))
|
||||
})
|
||||
|
||||
test("redactHostAndUrls: redacts every session host, not just the report's own", () => {
|
||||
// Crash-report case: report.host is undefined but earlier failed-connect
|
||||
// log lines mention hosts without a scheme.
|
||||
const text = ['{"host":"box-a.tailnet.ts.net","port":"4096"}', "server unreachable box-b.local:8080"].join("\n")
|
||||
const out = redactHostAndUrls(text, [undefined, "box-a.tailnet.ts.net", "box-b.local"])
|
||||
assert.ok(!out.includes("box-a.tailnet.ts.net"))
|
||||
assert.ok(!out.includes("box-b.local"))
|
||||
})
|
||||
|
||||
test("redactHostAndUrls: blanks bare IPv4 addresses even when unknown", () => {
|
||||
const out = redactHostAndUrls("connect failed 192.168.1.50:4096 via 10.0.0.1", [])
|
||||
assert.ok(!out.includes("192.168.1.50"))
|
||||
assert.ok(!out.includes("10.0.0.1"))
|
||||
assert.ok(out.includes("<redacted-ip>"))
|
||||
})
|
||||
|
||||
@@ -15,6 +15,19 @@ export function scrubString(s: string): string {
|
||||
return s.replace(/https?:\/\/\S+/g, (m) => scrubUrl(m))
|
||||
}
|
||||
|
||||
// Harder redaction for text that leaves the device (support inbox): drop
|
||||
// every URL wholesale, erase every known server host (bare `host=…` fragments
|
||||
// and log lines carry hosts without a scheme, which the URL regex misses),
|
||||
// and blank bare IPv4 addresses as a catch-all for hosts we never parsed.
|
||||
export function redactHostAndUrls(text: string, hosts?: Array<string | undefined>): string {
|
||||
let out = text.replace(/https?:\/\/[^\s)\]}"']+/gi, "<redacted-url>")
|
||||
for (const host of hosts ?? []) {
|
||||
if (host) out = out.split(host).join("<redacted-host>")
|
||||
}
|
||||
out = out.replace(/\b(?:\d{1,3}\.){3}\d{1,3}\b/g, "<redacted-ip>")
|
||||
return out
|
||||
}
|
||||
|
||||
export function scrubObject(obj: Record<string, unknown>): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {}
|
||||
for (const [k, v] of Object.entries(obj)) {
|
||||
|
||||
Reference in New Issue
Block a user