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:
Den
2026-07-16 23:08:40 -07:00
committed by GitHub
parent 004fa13795
commit 0bedad366b
8 changed files with 369 additions and 4 deletions

View File

@@ -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>"))
})