Files
opencode-mobile/src/lib/chatwoot.ts
Den 0bedad366b 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>
2026-07-16 23:08:40 -07:00

144 lines
4.9 KiB
TypeScript

// 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
}