Skip to content

feat(support): give Crisp agents the account context they keep asking for - #2851

Merged
innolope-dev merged 17 commits into
devfrom
feat/crisp-support-context
Aug 28, 2026
Merged

innolope-dev merged 17 commits into
devfrom
feat/crisp-support-context

Conversation

@innolope-dev

@innolope-dev innolope-dev commented Aug 27, 2026 •

Copy link
Copy Markdown
Collaborator

Follow-up to #2736. That PR gave the agent sidebar identity, wallet links and verification state. It gave it nothing about what the user actually has or just did, so a thread still opens with three rounds of "what's your balance / what did you last do / which build are you on".

Worth stating plainly, because it framed the whole design: none of this needs database access, and none of it grants any. Crisp never had a connection to our data — the client pushes session:data. The ceiling was never "DB vs no DB", it was "what does the client already know". It already knows all of this.

New sidebar rows

Every value comes from the /get-user read-models already in authContext plus warm react-query caches. No new endpoint, no backend change.

field what it answers
balance spendable total, wallet and card halves reported separately
account_stats points, streak, tenure, activation milestone, invite provenance, badges, queue position
latest_activity kind/provider, status, amount, age, failReason, uuid
limits_remaining per-provider fiat headroom — "why was my withdrawal blocked"
card application state, collateral, issued cards
linked_accounts rail shapes and countries
app_context platform, all three build layers, locale, route at open, connectivity, notification permission
sentry_issues Sentry issue search scoped to this user

Plus Crisp segments for the boolean half — ios-native, kyc-pending, verification-blocked, zero-balance, balance-unavailable, offline, guest, card-holder. Segments are what the inbox filters and routes on, which is the right home for yes/no facts, and keeping them out of session:data is what stops the sidebar becoming a wall of yes/no rows nobody scrolls past.

app_context is the one I'd single out as under-rated: an agent currently sees "Chrome 150 on Linux" and nothing about our app. Three version layers can disagree — the web bundle (a document can outlive arbitrarily many deploys, see useStaleDeploymentReload), the native binary, and the Capgo OTA bundle on top of it. An agent who can see all three says "update the app" instead of debugging a bug that was fixed a fortnight ago.

Three things worth reviewing carefully

No new requests. SupportDrawer mounts this hook app-wide — every screen, guests included. A useWallet() / useLimits() / useRainCardOverview() call inside it would switch on a 30s RPC poll plus two API requests for every user, which is precisely the shape that made /rain/cards the most-called endpoint in the app. All reads go through support-cache.ts, which only looks at what is already in the query cache, and useCrispUserData.test.tsx asserts the hook leaves the cache empty.

Unreadable is never zero. rainCentsToUsdcUnits(undefined) is 0n, so "no answer" and "genuinely empty" are arithmetically identical — the $0-balance bug (PEANUT-UI-QD5) in its support-facing form. An agent reading $0.00 tells a funded user they have no money, which is worse than telling them nothing. Each half is either a figure or the word unavailable, and the total only appears when both halves are answers. A user with no card application is still a real zero and prints as one.

The user's own state, never a counterparty's. HistoryEntry carries the other party's username and full name. A support console is the wrong place to accumulate third parties' payment records, so activity summaries name the kind, status and amount — the uuid is enough for an agent to look up the rest. Same rule for linked_accounts: iban:DE, never the IBAN.

Drift fix

The three sinks (web widget, proxy iframe, native setString) each wrote the field list by hand, and native had fallen to two keys where web sent seven — so the agents helping app users saw the least. There is now one supportSessionFields definition feeding all three, with a test pinning it. Native holds a single segment (CrispSDK.session.segment = … is an assignment, not an append — same on Android), so it gets the most actionable one via primarySupportSegment and the full list rides along as a data row.

Tests

60 passing across the touched suites — 25 on the pure builders, 4 on the cache readers, 5 on sink parity, 4 on the hook (including the no-fetch invariant), plus the existing Crisp/SupportDrawer/verification suites unchanged.

⚠️ Release-blocker: merge the disclosure first

The privacy policy's third-party table described Crisp as receiving "your messages, email address, and basic device data". This PR widens that considerably, so the disclosure had to move first.

It has. mono/content/legal/privacy/en.md now reads:

Your messages, email address, app version, basic device data, and a snapshot of your account context — shared only when you open support chat

peanutprotocol/mono@26bd1817, last_updated bumped. The mirror synced and the publish PR is open: #2855 (base main).

It cannot ship atomically with this PR — the policy lives in the src/content submodule and the repo rule is that content and code never travel together — so the guarantee is merge order instead:

#2855 must merge before the release PR that carries this code to production. This PR targets dev, so nothing here is live until a dev → main release; #2855 is one click from live today. Comfortable ordering, but it is a real gate, not a nicety.

No change to processor scope: Crisp was already a named processor in that same row, and this widens what it receives inside an existing relationship rather than adding one. Retention is Crisp-side and untouched.

The gap that let this nearly slip is now closed at the source — skills/peanut-pr phase 6b never named content/legal/ in its docs-impact search scope, so the subagent would not have opened the privacy policy. It now asks the legal question separately and requires quoting the sentence that is wrong (peanutprotocol/mono@0a08a271).

… for

Follow-up to #2736. The agent sidebar carries identity, wallet links and
verification state; it carries nothing about what the user actually has or
just did, so every thread still opens with three rounds of "what's your
balance / what did you last do / which build are you on".

None of this needs database access — Crisp never had any. The client pushes
`session:data`, so the ceiling was never "DB vs no DB", it was "what does the
client already know". It knows all of this already.

New sidebar rows, all derived from the /get-user read-models and warm
react-query caches:

  balance          spendable total, wallet and card halves reported separately
  account_stats    points, streak, tenure, milestone, invite provenance, badges
  latest_activity  kind/provider, status, amount, age, failReason, uuid
  limits_remaining per-provider fiat headroom
  card             application state, collateral, issued cards
  linked_accounts  rail shapes and countries
  app_context      platform, all three build layers, locale, route, connectivity
  sentry_issues    Sentry search scoped to this user

Plus Crisp segments (platform, kyc-*, verification-blocked, zero-balance,
offline…) for the boolean half — those are what the inbox filters and routes
on, and keeping them out of session:data is what stops the sidebar becoming a
wall of yes/no rows.

Three things worth calling out:

- **No new requests.** SupportDrawer mounts this hook app-wide, for guests
  too. A useWallet()/useLimits()/useRainCardOverview() call inside it would
  start a 30s RPC poll and two API requests for every user on every screen —
  the shape that made /rain/cards the most-called endpoint in the app. Reads
  go through support-cache, which only looks at what is already cached, and a
  test asserts the hook leaves the query cache empty.

- **Unreadable is never zero.** rainCentsToUsdcUnits(undefined) is 0n, so "no
  answer" and "genuinely empty" are arithmetically identical (PEANUT-UI-QD5).
  An agent reading "$0.00" tells a funded user they have no money, so each
  half is either a figure or the word `unavailable`, and the total only
  appears when both halves are answers.

- **The user's own state, never a counterparty's.** HistoryEntry carries the
  other party's username and full name; a support console is the wrong place
  to accumulate third parties' payment records. Activity summaries name the
  kind, status and amount, and the uuid is enough to look the rest up.

Also fixes a drift the new fields would have widened: the three sinks (web
widget, proxy iframe, native setString) each wrote the field list by hand, and
native had fallen to two keys where web sent seven — so the agents helping
*app* users saw the least. There is now one `supportSessionFields` definition
feeding all three. Native holds a single segment (assignment, not append), so
it gets the most actionable one and the full list rides as a data row.

The privacy policy's Crisp row still describes "messages, email address, and
basic device data" and needs widening to match — that lives in peanut-content
and ships as its own PR.
@coderabbitai

coderabbitai Bot commented Aug 27, 2026 •

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 09a6526c-51d6-4be7-8c54-8058f0b685e6

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Comment @coderabbitai help to get the list of available commands.

@innolope-dev innolope-dev self-assigned this Aug 27, 2026
@coderabbitai

coderabbitai Bot commented Aug 27, 2026 •

Copy link
Copy Markdown
Contributor
⚠️ Action not completed

Review rate limited.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@github-actions

github-actions Bot commented Aug 27, 2026 •

Copy link
Copy Markdown
Contributor

Code-analysis diff

Painscore total: 7643.75 → 7669.11 (+25.36)
Findings: +8 net (+41 new, -33 resolved)

🆕 New findings (41)

  • critical complexity — src/utils/support-context.ts — CC 76, MI 57.13, SLOC 203
  • critical complexity — src/components/Global/SupportDrawer/index.tsx — CC 73, MI 61.4, SLOC 219
  • critical complexity — src/hooks/useNotifications.ts — CC 61, MI 55.83, SLOC 299
  • high hotspot — src/context/authContext.tsx — 50 commits, +325/-200 lines since 6 months ago
  • high complexity — src/utils/crisp.ts — CC 47, MI 62.34, SLOC 81
  • high complexity — src/app/crisp-proxy/page.tsx — CC 41, MI 60.14, SLOC 140
  • high hotspot — src/components/Global/SupportDrawer/index.tsx — 34 commits, +733/-291 lines since 6 months ago
  • medium high-mdd — src/components/Global/SupportDrawer/index.tsx:29 — SupportDrawer: MDD 120.8 (uses across many lines from declarations)
  • medium high-mdd — src/components/Global/SupportDrawer/index.tsx:208 — : MDD 54.5 (uses across many lines from declarations)
  • medium high-mdd — src/components/Global/SupportDrawer/index.tsx:347 — : MDD 40.5 (uses across many lines from declarations)
  • medium high-mdd — src/context/ModalsContext.tsx:39 — ModalsProvider: MDD 40.8 (uses across many lines from declarations)
  • medium high-mdd — src/app/crisp-proxy/page.tsx:106 — CrispProxyPage: MDD 35.8 (uses across many lines from declarations)
  • medium high-mdd — src/app/crisp-proxy/page.tsx:107 — : MDD 35.8 (uses across many lines from declarations)
  • medium high-mdd — src/hooks/useCrispUserData.ts:160 — buildCrispUserData: MDD 27.8 (uses across many lines from declarations)
  • medium high-mdd — src/hooks/useCrispUserData.ts:67 — useCrispUserData: MDD 23.6 (uses across many lines from declarations)
  • medium complexity — src/hooks/useCrispUserData.ts — CC 22, MI 55.35, SLOC 116
  • medium method-complexity — src/utils/crisp.ts:49 — supportSessionFields CC 22 SLOC 4
  • medium high-mdd — src/utils/crisp.ts:107 — setCrispUserData: MDD 22.3 (uses across many lines from declarations)
  • medium complexity — src/hooks/useSupportClientContext.ts — CC 20, MI 61.23, SLOC 87
  • medium method-complexity — src/components/Global/SupportDrawer/index.tsx:29 — CC 16 SLOC 98

…and 21 more.

✅ Resolved (33)

  • src/components/Global/SupportDrawer/index.tsx — CC 76, MI 60.23, SLOC 223
  • src/hooks/useNotifications.ts — CC 60, MI 55.42, SLOC 296
  • src/context/authContext.tsx — 48 commits, +298/-173 lines since 6 months ago
  • src/app/crisp-proxy/page.tsx — CC 41, MI 60.18, SLOC 140
  • src/utils/crisp.ts — CC 39, MI 61.48, SLOC 85
  • src/constants/routes.ts — 30 commits, +152/-70 lines since 6 months ago
  • src/components/Global/SupportDrawer/index.tsx:29 — SupportDrawer: MDD 110.6 (uses across many lines from declarations)
  • src/context/ModalsContext.tsx:38 — ModalsProvider: MDD 36.5 (uses across many lines from declarations)
  • src/app/crisp-proxy/page.tsx:104 — CrispProxyPage: MDD 35.8 (uses across many lines from declarations)
  • src/app/crisp-proxy/page.tsx:105 — : MDD 35.8 (uses across many lines from declarations)
  • src/utils/crisp.ts:48 — setCrispUserData: MDD 25.2 (uses across many lines from declarations)
  • src/components/Global/SupportDrawer/index.tsx:256 — : MDD 22.5 (uses across many lines from declarations)
  • src/utils/crisp.ts:48 — setCrispUserData CC 22 SLOC 28
  • src/components/Global/SupportDrawer/index.tsx:163 — CC 18 SLOC 42
  • src/components/Global/SupportDrawer/index.tsx:29 — CC 16 SLOC 94
  • src/hooks/useCrispUserData.ts — CC 12, MI 60.7, SLOC 37
  • src/context/ModalsContext.tsx — CC 7, MI 62.93, SLOC 52
  • src/app/crisp-proxy/page.tsx:105 — useEffect with empty deps + setState — derived state anti-pattern
  • src/app/crisp-proxy/page.tsx:95 — direct DOM: document.createElement
  • src/components/Global/SupportDrawer/index.tsx:89 — small useEffect that only sets state from deps

…and 13 more.

📈 Painscore deltas (top movers)

File Before After Δ
src/utils/support-context.ts 0.0 9.4 +9.4
src/hooks/useSupportClientContext.ts 0.0 6.9 +6.9
src/utils/support-cache.ts 0.0 3.6 +3.6
src/hooks/useCrispUserData.ts 8.0 10.8 +2.8
src/utils/crisp.ts 8.0 8.9 +1.0

@github-actions

github-actions Bot commented Aug 27, 2026 •

Copy link
Copy Markdown
Contributor

🧪 UI test report — ✅ all green

Suites

  • ✅ unit: 4009 ran, 0 failed, 0 skipped, 53.0s

📊 Coverage (unit)

metric %
statements 71.5%
branches 57.0%
functions 63.3%
lines 72.4%
⏱ 10 slowest test cases
time test
2.9s src/components/Card/share-asset/__tests__/shareAssetLayout.test.ts › never places two stickers in heavy overlap (broad seed sweep)
1.0s src/hooks/query/__tests__/user.test.tsx › does NOT clear a token that rotated mid-request (stale 401 racing a fresh login)
0.4s src/utils/__tests__/sentry.utils.test.ts › defaults to the client budget under a browser global
0.4s src/utils/__tests__/crisp.test.ts › configures once across repeated support opens
0.3s src/utils/__tests__/crisp.test.ts › settles, and hands back a usable plugin, against a real-shaped plugin proxy
0.3s src/app/(mobile-ui)/withdraw/__tests__/withdraw-states.test.tsx › Bank withdrawal keeps the $1 minimum for sub-$1 amounts
0.3s src/hooks/__tests__/useCrispTokenId.test.ts › retries then stays undefined when the endpoint keeps failing (no fallback token)
0.3s src/app/(mobile-ui)/withdraw/__tests__/withdraw-states.test.tsx › the lazy bank view survives a re-render without blanking
0.3s src/utils/__tests__/sentry.utils.test.ts › still lets a per-call timeoutMs win over the default
0.3s src/utils/__tests__/crisp.test.ts › retries configuration on the next open after a failure
📍 Inline annotations are in the **Unit test report** check above. Coverage artifact: `coverage-unit`. Generated by `.github/workflows/tests.yml`.

@vercel

vercel Bot commented Aug 27, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
peanut-wallet Ready Ready Preview Aug 28, 2026 12:11am

Request Review

…dCrispUserData

The assembler had grown to 71 lines declaring values used far below their
declaration. buildSupportLinks and buildAppContext are the two clusters that
were pure string construction with no reason to sit inline.

@chip-peanut-bot chip-peanut-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Chip review: Changes requested

Request changes: Crisp segments accumulate stale routing flags, and the expanded financial and verification payload is not covered by the pinned privacy disclosure.

Findings

  • MAJOR · src/utils/crisp.ts:126 · Replace segments instead of appending them
    setCrispUserData runs again whenever the snapshot changes, but Crisp appends session segments unless the overwrite argument is true. A user who is briefly offline or balance-unavailable therefore keeps that routing tag after recovery and can accumulate contradictory tags, so inbox filters and routes act on stale state. Pass the overwrite flag and add a regression test covering two successive snapshots.

  • MAJOR · src/utils/crisp.ts:66 · Update the disclosure before expanding the Crisp payload
    These new rows send balance, account stats, latest activity and failure reason, limits, card state, and linked-account metadata to Crisp. The pinned PR description states that the current privacy policy discloses only messages, email, and basic device data, while this head leaves the content pointer unchanged. Deploying it would begin broader third-party processing whenever support opens without an accurate published disclosure. Gate the new fields until the updated policy is live and processor scope and retention are confirmed, or ship the disclosure change atomically.

Checked clean

  • Exact head, base, merge base, trusted author, title, and description
  • Cache-only balance, card, limits, and transaction-history reads
  • Unknown-balance handling, in-transit arithmetic, and counterparty redaction
  • Web, proxy, and native field parity plus logout reset paths
  • Platform, build, route, connectivity, and notification context
  • Targeted support-context suite: 25 tests passed
  • Diff hygiene: git diff --check passed

Second opinion skipped: openrouter-timeout.

Exact head: 3b9acb4bbcc1 · Context: repo, pull_request_description, crisp_official_docs

Comment thread src/utils/crisp.ts Outdated
Comment thread src/utils/crisp.ts
Crisp appends session segments unless the second push argument is true, and
setCrispUserData runs again on every snapshot change. So the segment set only
ever grew: a user who was briefly offline kept routing as `offline` after
recovery, and `kyc-pending` outlived their approval. The inbox would then
filter and route on state the user had already left — worse than shipping no
segments at all, because a stale tag reads as current.

Native is unaffected: its setSegment is an assignment (CrispSDK.session.segment
= …), so it already replaces.

Two regression tests cover it — one pinning the overwrite flag, one pushing two
successive snapshots and asserting the superseded segment is gone. Both fail
without the flag.

Found by Chip on #2851.

@chip-peanut-bot chip-peanut-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Chip review — changes requested

Request changes: the new route latch can retrigger the native Crisp open flow and duplicate prefilled messages. Both earlier findings are fixed.

Findings

  • MAJOR · src/hooks/useSupportClientContext.ts:92 · Prevent the route latch from opening native Crisp twice
    On the first Capacitor support open, isSupportModalOpen becomes true and this passive effect updates routeOnOpen after the same commit has already started SupportDrawer's async native open effect. The route change rebuilds userData; because that object is an effect dependency and there is no in-flight/open-cycle guard, a second chain can call sendMessage and openMessenger before the first finishes, duplicating any prefilled support message. Capture the route without a post-open state transition, or guard/split the native sync/open effect so each open cycle sends and opens exactly once; cover this case in a native SupportDrawer test.

Checked clean

  • Exact head, supplied base SHA, and merge base verified.
  • Earlier segment finding FIXED: session:segments now uses the overwrite flag and has regression coverage.
  • Earlier disclosure finding FIXED: the production privacy copy now discloses the account-context snapshot; publish PR #2855 is merged.
  • Exact-head CI is green across unit, e2e, typecheck, eslint, format, analyze, and deployment checks.
  • Reviewed cache-only balance, card, limits, and activity reads plus web, proxy, and native Crisp sinks; no new query subscription was introduced.
  • Reviewed support-data trust boundaries, session reset behavior, postMessage origin/source checks, and omission of transaction counterparties and bank identifiers.
  • No issue comments or review comments were fetched or read.

Second opinion skipped: openrouter-timeout.

Exact head: dcfcc098f087 · Context: repo, content, peanut-api-ts

Comment thread src/hooks/useSupportClientContext.ts Outdated
The route latch set state in an effect, so it landed after the commit that
opened support — and on native that commit has already started SupportDrawer's
async open chain. The extra render rebuilt `userData`, which is a dependency of
that effect, so a second chain reached sendMessage/openMessenger before the
first finished and a prefilled support message was sent twice.

Two changes, because the latch was the trigger but not the whole hazard:

- The route is now latched DURING RENDER. React re-runs the component before
  anything commits, so the first snapshot support ever sees already carries the
  route — no post-open transition at all. This also keeps `route:` in the
  native app_context row, which an open-cycle guard alone would have dropped.

- The native effect takes an open-cycle guard. `userData` is a live snapshot:
  a balance landing from the cache changes its identity too, and any such
  change during the async window had the same double-send shape. The guard
  closes it for every cause. Reset on close and in the catch, so the next open
  and a retry after a failed open both still run.

Tests: a prefilled message survives a mid-open snapshot change exactly once
(fails without the guard — 3 sends), and the token-still-resolving path still
opens, so the guard cannot latch a waiting cycle shut.

Found by Chip on #2851.

@chip-peanut-bot chip-peanut-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Chip review — changes requested

The native messenger now opens once, but the latch sends the account snapshot captured before asynchronous setup instead of the latest state.

Findings

  • MAJOR · src/components/Global/SupportDrawer/index.tsx:215 · Send the latest snapshot through the native open latch
    If userData changes while Crisp configuration or camera permission is pending (the added test models balance changing from unavailable to $100), the latch prevents a second effect but this continuation still closes over the first object. Native setString and setSegment therefore publish the stale balance and balance-unavailable routing even though the newer snapshot exists before the messenger opens. Keep the latest user data in a ref and read it after the await, while using the latch only to gate sendMessage and openMessenger.

Checked clean

  • Exact detached head and supplied base SHA verified; the worktree is clean.
  • PR author, base ref, base SHA, and head SHA match the supplied trusted metadata.
  • Earlier native duplicate-open finding: FIXED; the new latch and regression test keep sendMessage and openMessenger to one call per open cycle.
  • Earlier segment accumulation finding: FIXED; web session segments now use Crisp's overwrite flag and tests pin replacement behavior.
  • Earlier disclosure finding: FIXED; canonical content now discloses the account-context snapshot in the Crisp processor row.
  • Cache readers remain read-only and do not subscribe to or create TanStack queries.
  • Balance unknown-state handling, third-party activity redaction, support link construction, session-field sink parity, and logout cache clearing were reviewed.
  • All reported CI checks at the exact head completed successfully; the skipped manual check is non-gating.

Second opinion skipped: openrouter-timeout.

Exact head: 2e2eeb594559 · Context: repo, content

Comment thread src/components/Global/SupportDrawer/index.tsx Outdated
…efore setup

The open-cycle guard from the previous commit closed the double-send window but
opened a staleness one. The chain awaits Crisp configuration and the camera
permission; the guard means a snapshot landing during that window gets no second
chance to publish, and the continuation still read `userData` from the effect
closure. So native published whatever was true when support was tapped — an
agent could open the sidebar on a balance the user no longer has, and route on
a `balance-unavailable` segment they had already left.

The chain now reads the payload ref after its awaits. The ref already existed
for the proxy handshake and is refreshed on every commit, so it is renamed
`latestPayloadRef` to say what it is and both sinks read the same latest state.
The token and the prefill come from it too — the same window applied to them,
and a stale token is the session-isolation bug this component's own gate exists
to prevent.

Test asserts the later balance and the later segment reach native, and fails
against the closure ("wallet unavailable" instead of "$100.00 spendable").

Found by Chip on #2851.

@chip-peanut-bot chip-peanut-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Chip review — changes requested

Two major issues remain: cache updates after the open render do not refresh the support snapshot, and account_stats sends an inviter's username despite the user-only privacy boundary.

Findings

  • MAJOR · src/hooks/useCrispUserData.ts:79 · Subscribe the open snapshot to cache updates
    These imperative cache reads are not subscriptions: useQueryClient() stays stable when setQueryData or a query completion updates the cache. If support opens while balance, card, or history data is pending, that completion does not re-render SupportDrawer, so latestPayloadRef remains at unavailable and Crisp receives stale values and routing segments; the new test only observes an update after explicitly rerendering. Subscribe read-only to the relevant cache changes, ideally only while support is open and without enabling any fetches, or otherwise force a snapshot recompute before publishing.

  • MAJOR · src/utils/support-context.ts:91 · Do not send the inviter's username to Crisp
    IUserProfile.invitedBy is explicitly another person's username, so opening support as a referred user now copies a third party's identifier into Crisp. That contradicts this module's user-only invariant and the updated disclosure that Crisp receives the current user's account context. Omit the username or reduce this field to a non-identifying boolean such as referred:true.

  • MAJOR · src/utils/support-cache.ts:53 · [moonshotai/kimi-k3] readLatestHistoryEntry scans other users' cached history (targetUsername variants) and pushes it as the user's own activity
    The scan uses findAll({ queryKey: [TRANSACTIONS] }) and aggregates entries from every cached variant, including keys like [TRANSACTIONS, 'latest', { limit, targetUsername: 'bob' }] — the docblock explicitly acknowledges targetUsername variants exist but only guards against limit differences. Concrete failure: the user views another user's activity (targetUsername set), then opens support; if that entry is the newest in the cache, buildLatestActivity serializes its kind/status/amount/failReason/uuid into latest_activity and it is pushed to Crisp as THIS user's latest movement. That both misleads the agent (they troubleshoot a transaction the user never made) and replicates a third party's payment record into the Crisp console — exactly what the PR's stated invariant #2 ('the user's own state, never a counterparty's') and the removal of counterparty names were designed to prevent. Fix: while scanning, skip queries whose key carries a targetUsername different from the logged-in user (e.g. inspect query.queryKey[2]?.targetUsername), or restrict the scan to keys with no targetUsername.

  • MINOR · src/components/Global/SupportDrawer/index.tsx:195 · [moonshotai/kimi-k3] Native open latch resets on close while the in-flight chain is still awaiting, so close/reopen still double-sends the prefilled message
    nativeOpenStartedRef is set when the chain starts and reset to false the moment isSupportModalOpen flips false, but the already-started chain (awaiting ensureNativeCrispConfigured / ensureNativeCameraPermission) is not invalidated. Concrete failure: user opens support, the chain parks on the camera-permission await, the user dismisses the drawer (ref resets) and reopens it — a second chain starts, both chains complete, and sendMessage/openMessenger each run twice, the exact duplicate-message bug this PR sets out to kill ('closes that window for every cause'). The same gap means a chain started before a close still opens the messenger and posts the prefilled message after the user has dismissed support, since nothing is re-checked after the awaits. Fix: use a generation counter instead of a boolean — increment it on close, capture it at chain start, and bail out after the awaits (before setUser/sendMessage/openMessenger) if the captured generation no longer matches.

  • MINOR · src/hooks/useSupportClientContext.ts:105 · [moonshotai/kimi-k3] routeOnOpen latches the raw pathname, which can embed a counterparty identifier from dynamic route segments
    routeOnOpen is latched from usePathname() verbatim and pushed to Crisp inside app_context. For any route with a user-identifying dynamic segment (e.g. a payment-link or profile path such as /pay/ or /), the route row names the third party the user was paying or viewing when they opened support — the same class of counterparty leak the PR deliberately removed from latest_activity ('a support console is the wrong place to accumulate third parties' payment records'). Concrete failure: user opens a payment link to another user, hits an error, opens support from that page; the agent sidebar now shows route:/pay/. Fix: normalize the pathname before latching — map it against known route patterns and replace dynamic segments with a placeholder (e.g. route:/pay/:user), or whitelist static route prefixes.

Checked clean

  • Pinned head, base SHA, base ref, trusted author, and merge base all matched the supplied values.
  • Exact-head CI is green across unit, typecheck, eslint, format, e2e, and aggregate gates; a focused local rerun could not start because dependencies are not installed in the detached worktree.
  • Earlier useSupportClientContext route-latch finding: FIXED; the route is latched before commit and the native open chain is guarded per open cycle.
  • Earlier SupportDrawer stale-effect-closure finding: FIXED; the native continuation reads the latest committed payload after its awaits.
  • Reviewed token gating, logout cache clearing, balance-unavailable handling, session-field parity, segment replacement, native primary-segment selection, query-key alignment, and proxy update flow.
  • git diff --check passed.

Second opinion by moonshotai/kimi-k3: 3 finding(s), marked with the model name. It reads the diff only, so treat its findings as advice.

Exact head: f5f8aee9dbbc · Context: repo, product

Comment thread src/hooks/useCrispUserData.ts Outdated
Comment thread src/utils/support-context.ts Outdated
Comment thread src/utils/support-cache.ts Outdated
Comment thread src/components/Global/SupportDrawer/index.tsx Outdated
Comment thread src/hooks/useSupportClientContext.ts
Four findings from Chip's fourth pass.

Subscribe the open snapshot to cache updates. The cache reads are not
subscriptions, so a query resolving after support opened never reached the
sidebar: open while the balance is in flight and the agent read `unavailable`
for the whole conversation, routing on a `balance-unavailable` segment —
accurate at the instant it was read, wrong a second later. The hook now watches
the query cache, read-only and only while support is open. It never triggers a
fetch, which is the point of reading the cache rather than subscribing with
useQuery, and the signature memo bounds the re-render churn: an event that
changes no value leaves the snapshot's identity alone.

Stop sending the inviter's username. `invitedBy` is another person's handle,
and shipping it contradicted this module's own stated invariant. That the user
was referred is useful to an agent; who referred them is not ours to forward.
Reduced to `referred:yes`.

Normalize the route before latching it. `/pay/bob` named the counterparty the
user was paying — the same leak the activity row was built to avoid. Segments
under identifier-bearing prefixes become `:id`, and because the root
`[...recipient]` catch-all makes any unlisted single segment a possible handle,
anything unrecognised degrades to `/:recipient`. Fail-safe by construction:
forgetting to list a new static route costs precision, never privacy.
`/withdraw/manteca` still names the rail, which names nobody.

Replace the native open latch with a generation counter. The boolean assumed
the chain could not outlive its cycle. It can: close the drawer while it is
parked on the camera-permission await and reopen, and the cleared flag lets a
second chain start — the duplicate prefill was back. The same gap let a chain
from a dismissed cycle open the messenger after the user walked away. Closing
now increments a generation, the chain captures it, and everything after the
awaits is conditional on it still being current.

One finding not taken: the history scan reading other users' rows. /users/history
is always scoped to the authenticated userId — targetUsername resolves to a
filter, not a subject (peanut-api-ts src/routes/user/history.ts:167-173). Every
cached variant holds this user's own transactions. Reasoning recorded on the PR.
…e guards

Four rounds of review on this PR produced four defects in one place, and each
fix seeded the next: a duplicate prefill, a stale payload, a duplicate again
across close/reopen. That is a shape, not bad luck. The effect both published
live data and performed a one-shot action, and it listed the data it publishes
in its dependencies — so every snapshot change re-entered a function whose tail
sends a message and opens a window, and each guard bolted on to stop that
introduced the next gap.

The effect now depends only on what defines an open cycle: the drawer being
open, and the token being ready. `userData`, `crispTokenId` and
`prefilledMessage` are read from `latestPayloadRef` at the moment the chain
publishes, which is where they were already being read from, so they never
belonged in the deps.

That deletes both generation refs and the boolean before them. What is left is
React's own idiom: one cycle runs the chain once, cleanup sets `cancelled`, and
a chain from a cycle the user has left stops before it can open a messenger
they walked away from. Four coordinating mechanisms become two — the token gate
and cancellation — and the file loses 24 lines net.

All 23 SupportDrawer tests pass untouched, including every regression from the
four rounds: single prefill under a mid-open snapshot change, latest-snapshot
publication, close/reopen, dismissed cycle, and the token-resolution path. That
they still pass against a different mechanism is the point — they pin behaviour,
not implementation.

One behaviour change worth naming: a failed configure no longer retries on the
next unrelated re-render. That retry was incidental, not designed — it only ever
fired if some dependency happened to change. Reopening support retries properly,
and ensureNativeCrispConfigured clears its memo on failure so the retry really
re-configures.
@innolope-dev

Copy link
Copy Markdown
Collaborator Author

Follow-up: the native open effect, simplified (cda30e5)

Four rounds of review produced four defects in one place — duplicate prefill → stale payload → duplicate again across close/reopen — and each fix seeded the next. That is a shape, not bad luck, so rather than adding a fifth guard I went after the cause.

The effect both published live data and performed a one-shot action, and it listed the data it publishes in its dependency array. So every snapshot change re-entered a function whose tail calls sendMessage and openMessenger. Each guard bolted on to stop that opened the next gap.

It now depends only on what defines an open cycle — the drawer being open, and the token being ready. userData, crispTokenId and prefilledMessage are read from latestPayloadRef at the moment the chain publishes, which is where they were already read from, so they never belonged in the deps.

That deletes both generation refs and the boolean before them. What remains is React's own idiom:

useEffect(() => {
    if (!isSupportModalOpen || !isCapacitor() || isAwaitingToken) return
    let cancelled = false
    ensureNativeCrispConfigured().then(async ({ CapacitorCrisp }) => {
        await ensureNativeCameraPermission()
        if (cancelled) return
        const { userData: snapshot, tokenId, prefilledMessage: prefill } = latestPayloadRef.current
        ...
    })
    return () => { cancelled = true }
}, [isSupportModalOpen, isAwaitingToken, setIsSupportModalOpen, clearSupportBadge])

Four coordinating mechanisms become two — the token gate and cancellation. Net −24 lines, and the react-hooks/exhaustive-deps warning the closure carried is gone rather than suppressed.

All 23 SupportDrawer tests pass untouched, including every regression from the four review rounds: single prefill under a mid-open snapshot change, latest-snapshot publication, close/reopen, dismissed cycle, token resolution. They were written against the guards and still pass against a different mechanism — which is the point, they pin behaviour rather than implementation.

One behaviour change worth naming rather than burying: a failed configure no longer retries on the next unrelated re-render. That retry was incidental — it only ever fired if some dependency happened to change — not designed. Reopening support retries properly, and ensureNativeCrispConfigured clears its memo on failure so the retry genuinely re-configures.

Gate: typecheck, eslint (0 errors, 0 warnings in this file), prettier, 1532 tests / 100 suites.

@chip-peanut-bot chip-peanut-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Chip review — changes requested

One major privacy defect remains: a guest snapshot can carry cached activity and limits from the prior authenticated session. All three previously reported defects are fixed.

Findings

  • MAJOR · src/hooks/useCrispUserData.ts:194 · Do not publish authenticated cache entries for guests
    When /users/me returns 401, auth becomes null but the existing [transactions] and [limits] queries remain cached. SupportDrawer is also mounted on the setup/guest layout, so opening support before those entries are garbage-collected serializes the prior user's transaction UUID/amount and limits here even though userId is absent. Gate these fields (and preferably the underlying cache reads) on the current authenticated user, and cover the auth-to-guest transition in the hook test.

  • MAJOR · src/utils/support-cache.ts:53 · [moonshotai/kimi-k3] latest_activity can report another user's transaction (targetUsername cache variants are scanned)
    readLatestHistoryEntry scans client.getQueryCache().findAll({ queryKey: [TRANSACTIONS] }), which matches every cached history query, keyed as [TRANSACTIONS, 'latest', { limit, targetUsername }] and infinite variants. The file's own comment acknowledges targetUsername is part of the key, but the scan does not filter on it. Reachable path: the user views someone else's profile/activity (the targetUsername variant exists for exactly that), that user's entries are cached, the user then opens support, and the newest entry across ALL variants wins — so buildLatestActivity summarizes a third party's transaction (kind, status, amount, failReason, uuid) and pushes it to the Crisp sidebar as the current user's own latest activity. That is precisely the counterparty-payment-record leak the module's header says it prevents, in attribution form, and it also misleads the agent ('your last transaction was X' when it wasn't theirs). Fix: in the findAll loop, skip any query whose key params carry a targetUsername that is defined and not the current user (e.g. const params = query.queryKey[2]; if (params?.targetUsername) continue), or restrict the scan to keys explicitly owned by the self-history queries.

Checked clean

  • FIXED: the open support snapshot now observes relevant React Query cache updates without starting requests.
  • FIXED: account stats report referral presence without including the inviter's username.
  • FIXED: the native open cycle publishes the latest committed snapshot after its awaits and cancels a dismissed cycle.
  • Exact-head CI completed successfully, including lint, typecheck, unit, e2e, format, and analysis gates.
  • Checked cache key scoping, passive auth-expiry behavior, guest SupportDrawer reachability, route redaction, sink parity, and native open-cycle concurrency.
  • Local targeted Jest could not run because the detached worktree has no installed Jest binary; exact-head unit CI is green.

Second opinion by moonshotai/kimi-k3: 1 finding(s), marked with the model name. It reads the diff only, so treat its findings as advice.

Exact head: cda30e57cd42 · Context: repo, ci

Comment thread src/hooks/useCrispUserData.ts Outdated
Comment thread src/utils/support-cache.ts Outdated
…d user

An explicit logout clears the query cache (authContext cancels then clears it),
but a session that simply EXPIRES does not: /users/me 401s, `user` becomes null,
and `[limits]` and `[transactions]` stay warm behind keys that carry no user id.
SupportDrawer is mounted on the guest and setup layouts too, so the next person
to open support on that device published the previous user's limits and last
transaction — uuid, kind, amount, status — under an empty `user_id`.

Worse than the counterparty leak this module was built to prevent: not a name
attached to the user's own transaction, but another person's state presented as
nobody's.

All four cache reads are now gated on an authenticated user. Balance and card
were already keyed by wallet address and user id, so this makes the rule uniform
rather than incidental — no authenticated cache entry is read without an
authenticated user, whatever the key looks like.

Test covers the auth-to-guest transition with a warm cache: nothing from it
reaches the snapshot, and the previous user's transaction id appears nowhere in
the serialized payload. Fails without the gate.

Also fixes phrasing spotted in that test's output: a transaction from seconds
ago read "just now ago".

Found by Chip on #2851.

@chip-peanut-bot chip-peanut-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Chip review — changes requested

Two major issues remain: userless warm caches can cross an account switch, and Android appends the dynamic primary Crisp segment instead of replacing stale routing state.

Findings

  • MAJOR · src/hooks/useCrispUserData.ts:128 · Scope warm caches to the authenticated account
    The new guest gate only suppresses cache data while auth is absent. After a passive /users/me 401, the userless [limits] and [transactions] entries remain warm; if a different account logs in on that device, isAuthenticated becomes true and these reads immediately publish the previous account's limits and latest transaction into the new account's Crisp session before refetch. Key these caches and readers by userId (or remove them whenever the authenticated user ID changes), and add an expiry-to-different-login test.

  • MAJOR · src/components/Global/SupportDrawer/index.tsx:249 · Overwrite Android segments instead of appending
    The pinned @capgo/capacitor-crisp 8.0.27 Android bridge implements setSegment with Crisp.setSessionSegment(segment), whose one-argument form keeps the SDK's default append behavior; only iOS assigns a single replacing segment. Reopening support after balance-unavailable, offline, or kyc-pending has cleared therefore adds the new primary segment but leaves the stale one routing the conversation. Expose the SDK's overwrite overload/setSessionSegments(..., true), or omit dynamic native routing until it can be replaced, and test the Android bridge semantics rather than only the JS mock call.

Checked clean

  • FIXED: authenticated cache entries are no longer published while the current snapshot is a guest.
  • FIXED: the open support snapshot now observes relevant React Query cache updates without starting requests.
  • FIXED: account stats report referral presence without including the inviter's username.
  • Pinned head, base SHA, base ref, trusted author, and merge base all matched the supplied values.
  • Exact-head CI completed successfully, including unit, typecheck, eslint, format, e2e, preview, and analysis gates.
  • The widened Crisp disclosure is present in the canonical privacy content, and its production content PR is merged.
  • Reviewed cache ownership across passive expiry and account switching, cache subscriptions, balance unknown-vs-zero handling, counterparty and route redaction, session-field parity, proxy updates, native open-cycle concurrency, and web/native segment semantics.
  • A local focused run passed all 32 pure support-context tests; suites requiring the detached worktree's missing dependency links could not resolve next-intl, while exact-head unit CI is green.

Second opinion skipped: openrouter-empty-reply.

Exact head: 560bfdd6b115 · Context: repo, ci, product, provider

Comment thread src/hooks/useCrispUserData.ts Outdated
Comment thread src/components/Global/SupportDrawer/index.tsx Outdated
…oid segment

Two findings from Chip's sixth pass.

Warm caches could cross an account switch. The guest gate from the last commit
only suppressed cache data while auth was absent; it did not stop the previous
account's rows reaching the NEXT one. After a passive 401 the unscoped `[limits]`
and `[transactions]` entries stay warm, so signing a different account in on that
device served them until each refetch landed.

The fix is in authContext, not here, because the leak is not the support
sidebar's: react-query serves that stale data to whatever reads it, so the new
user briefly saw someone else's Activity as well. An explicit logout already
clears the cache there; this makes an account CHANGE do the same. It fires only
between two different accounts — a first sign-in keeps what a guest legitimately
prefetched, e.g. a claim link opened before signing in.

Keying `[limits]` and `[transactions]` by user id would be the better end state,
but `[TRANSACTIONS]` is a prefix for eight invalidation sites and is used
directly in two more; that refactor deserves its own PR and review, not a ride
along in a support change.

Dropped the native Crisp segment. The plugin exposes only a one-argument
`setSegment`, which on Android calls `Crisp.setSessionSegment` with no overwrite
flag, so segments append: a stale `offline` or `balance-unavailable` keeps
routing a conversation after the user has left that state. A routing tag that is
wrong is worse than one that is missing. Nothing reaches the agent differently —
the `segments` data row carries the whole list and `setString` assigns, so it
replaces cleanly on every open.

`primarySupportSegment` existed only to pick that one segment and is removed
with it, along with the docblock asserting Android assigned like iOS — which was
the wrong assumption behind the bug.
…never could

`CapacitorCrisp.sendMessage` is declared `unimplemented` in this plugin on BOTH
iOS and Android — it is the only unimplemented method on iOS. So every "contact
support about X" entry point lost its context in the app: the composer stayed
empty and the agent opened the conversation blind, on both platforms, not just
Android as first reported.

It failed silently twice over. The call was never awaited, so `unimplemented`
rejected into nothing — an unhandled "Not implemented on ios" rejection behind
every native support open that carried a topic. That is a plausible second
source of the "plugin not implemented on ios" Sentry events previously put down
entirely to the /crisp-proxy iframe.

The topic now rides in a `support_topic` session-data row, through `setString`,
which both platforms do implement. It goes through the same
`supportSessionFields` definition as everything else, so all three sinks carry
it: web keeps the prefilled composer AND gains the row, which survives a user
clearing the text before sending.

The native composer still opens empty — the SDK gives no way to fill it — so the
user writes their own first message. What changes is that the agent already
knows what brought them there. JS only: this ships over OTA, with no native
release.

One existing test asserted `sendMessage` was called once per open cycle. It was
pinning a no-op; it now pins the data row that actually carries the topic.
@innolope-dev

Copy link
Copy Markdown
Collaborator Author

Native support prefill — fixed here after all (b73f642)

Correcting what I said in the thread above: this is not Android-only. sendMessage is declared unimplemented on both platforms, and on iOS it is the only unimplemented method in the plugin:

// ios/Sources/CapacitorCrispPlugin/CapacitorCrispPlugin.swift:143
@objc func sendMessage(_ call: CAPPluginCall) {
    call.unimplemented("Not implemented on iOS.")
}
// android/.../CapacitorCrispPlugin.java:215
public void sendMessage(PluginCall call) {
    call.unimplemented("Not implemented on Android.");
}

So every "contact support about X" entry point has been losing its context in the app on both platforms, and it failed silently twice over: the call was never awaited, so unimplemented rejected into nothing. That is an unhandled "Not implemented on ios" rejection behind every native support open carrying a topic — plausibly a second source of the "plugin not implemented on ios" Sentry events previously attributed entirely to the /crisp-proxy iframe. Worth a look at that issue with this in mind.

The fix routes the topic through setString, which both platforms do implement, as a support_topic session-data row. It goes through the same supportSessionFields definition as every other row, so all three sinks carry it — web keeps the prefilled composer and gains the row, which survives the user clearing the text before sending.

Being precise about what this does and does not restore: the native composer still opens empty, because the SDK offers no way to fill it. The user writes their own first message. What changes is that the agent is no longer blind to what brought them there. JS only, so it ships over OTA with no native release.

One existing test asserted sendMessage was called once per open cycle — it was pinning a no-op, and now pins the row that actually carries the topic. Worth noting that the same is true of part of the earlier review history on this PR: the "duplicate prefilled message" framing in rounds 2–4 was theoretical on native, since the call did nothing there. The duplicate openMessenger was real, and those fixes stand.

Gate: typecheck, eslint clean, prettier, full suite 3644 passed / 292 suites.

@chip-peanut-bot chip-peanut-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Chip review — changes requested

One previously reported major remains: unscoped warm caches can still be published under a newly authenticated account before the effect-based cleanup runs. The guest-cache and Android-segment findings are fixed.

Findings

  • MAJOR · src/hooks/useCrispUserData.ts:128 · Keep cached support data scoped to its account
    These readers still accept the global [LIMITS] entry and every [TRANSACTIONS] entry without proving they belong to userId. If account A expires while support is open and account B authenticates, B's render reads A's warm data here; SupportDrawer's child effect can publish it before AuthProvider's parent effect calls queryClient.clear(), and cache removals do not trigger this hook's watcher. Key these queries/readers by userId, or store and verify an owner before returning cached values; a post-render clear is not a privacy boundary.

  • MINOR · src/context/authContext.tsx:121 · [moonshotai/kimi-k3] Cross-account cache clear runs after SupportDrawer publishes, leaving a one-commit leak window
    The new effect clears the query cache when the signed-in account changes, but it lives in AuthProvider, the parent of SupportDrawer. React runs child effects before parent effects, so on the commit where user B's profile lands after user A's session expired passively (explicit logout already clears; expiry does not — the code's own comment calls out that [limits] and [transactions] carry no user id and stay warm), the ordering is: (1) SupportDrawer's child effect postMessages the fresh userData to the proxy iframe / answers a handshake pull from latestPayloadRef, computed with isAuthenticated === true for B but reading A's still-warm [LIMITS] and [TRANSACTIONS] cache entries via readCachedLimits/readLatestHistoryEntry; (2) only then does AuthProvider's effect run queryClient.clear(). The result is A's fiat limits and last transaction (kind/status/amount/uuid) pushed to the third-party Crisp console attributed to B's user_id — exactly the cross-account exposure this PR builds the isAuthenticated gate and this clear to prevent; the gate cannot help here because B is authenticated at that moment. Preconditions narrow the window (passive expiry, then sign-in as a different account while the support proxy iframe is alive, which the PR notes is possible since SupportDrawer mounts on guest/setup layouts), but it is reachable. Fix: clear before children render with the new id — e.g. run the clear in the login-success path that sets the new user (before any commit carrying B's data), or use React's adjust-state-during-render pattern in AuthProvider to detect the id change during render (parents render before children) and clear there, or have useCrispUserData treat cache-derived fields as invalid on the first render after a userId change.

Checked clean

  • Confirmed detached HEAD and merge base exactly match the supplied head and base SHAs.
  • Earlier guest-cache finding FIXED: authenticated cache reads are gated when user/userId are absent and covered by an expired-session test.
  • Earlier Android segment finding FIXED: native setSegment calls were removed and the current list is assigned through the segments data row.
  • Earlier account-scope finding STILL PRESENT: LIMITS and TRANSACTIONS cache keys remain account-agnostic and cleanup happens in a later effect.
  • Reviewed balance unknown-vs-zero handling, counterparty scrubbing, route normalization, sink field parity, native open-cycle behavior, and cache-only reads.
  • Exact-head CI is green for unit, typecheck, eslint, format, e2e, analyze, and deploy preview.
  • Local pure support-context suite passed 30 tests; four additional local suites could not resolve next-intl from the shared dependency install, so exact-head CI supplied the complete test evidence.

Second opinion by moonshotai/kimi-k3: 1 finding(s), marked with the model name. It reads the diff only, so treat its findings as advice.

Exact head: b73f6426db64 · Context: repo

Comment thread src/hooks/useCrispUserData.ts Outdated
Comment thread src/context/authContext.tsx Outdated
Drops `limits_remaining` and `latest_activity`, and reverts the cross-account
cache clear that was added to make them safe.

Those two fields were the only ones reading `[limits]` and `[transactions]`,
whose keys carry no user id. That is the root behind the last three review
findings — the guest leak, the cross-account leak, and the effect-ordering
window — each a different angle on the same hole, and each patch narrowed it
without closing it. React runs child effects before parent effects, so the
clear in AuthProvider could not protect a child that publishes in the same
commit: a post-render clear is not a privacy boundary.

Balance and card were never part of it. Their keys name the identity they
belong to — wallet address, user id — so a cached entry can be proved to belong
to the person support is open for. That is now the stated rule in
support-cache: only read a key that names its owner, and do not add a reader
that cannot answer "whose data is this?" from the key alone.

The two fields come back once `[limits]` and `[transactions]` are user-scoped.
That refactor belongs in its own PR: `[TRANSACTIONS]` is a prefix for eight
invalidation sites and used directly in two more, and scoping it also fixes the
home screen serving a previous account's Activity — a bug that has nothing to
do with support and should not be fixed inside a support change.

The cache clear is reverted for the same reason. It was app-wide, it was only
here to protect fields that no longer exist, and it carried a known ordering
flaw. The bug it addressed is real and is recorded for that PR.

The sidebar keeps balance, account stats, card, linked accounts, app context,
sentry link, verification state and support topic. Full suite green: 3636
passed across 292 suites.
Crisp segments are a field people write by hand, and one of them backs an OKR.
Agents tag translation reports `translation-issue`, and the monthly count only
sees a conversation that still carries the tag — untagged reports are invisible
to it (mono/ops/playbook/translation-issue-tag.md, TASK-20776). Ops tags
incidents the same way.

Crisp has no partial write: a set replaces the whole list. So the app's write
erased whatever a human had put there, and it runs on every snapshot change
rather than only on open — an agent could tag a conversation and watch the tag
vanish while the user was still in it.

Appending instead is not a fix. That was the original behaviour; it leaves the
app's own flags stale, so a user who was briefly offline routes as `offline`
for good. Neither mode is safe while humans and the app share one field, so the
app writes none.

Nothing is lost to the agent. The same flags already ride as the `segments` row
in the sidebar, which is where they were readable anyway. What the app gives up
is filtering the inbox by its own state — a capability that never existed and
that nobody has asked for, against an OKR count that exists and is load-bearing.

Deliberately a deletion, not a redesign: the `segments` row, buildSupportSegments
and every other field are untouched. The design question is TASK-21968.
@innolope-dev

Copy link
Copy Markdown
Collaborator Author

Segment write removed (1d87998)

Closing the last open item before merge. The app no longer writes Crisp segments at all.

Why, in one line: segments are a field people write by hand, and one of them backs an OKR. Agents tag translation reports translation-issue, and per ops/playbook/translation-issue-tag.md the monthly count only sees a conversation that still carries the tag. Ops tags incidents the same way (card-withdraw-blocked).

Crisp has no partial write — a set replaces the whole list — so the app's write erased whatever a human had put there. And it ran on every snapshot change, not just on open, so an agent could tag a conversation and watch the tag vanish while the user was still in it.

Appending is not the alternative: that was the original behaviour, and it leaves the app's own flags stale (offline outliving reconnection). Neither mode is safe while humans and the app share one field, so the app writes none.

Nothing is lost to the agent. The same flags already ride as the segments row in the sidebar. What the app gives up is filtering the inbox by its own state — a capability that has never existed and that nobody asked for, weighed against an OKR count that exists and is load-bearing today.

Deliberately a deletion rather than a redesign: the segments row, buildSupportSegments, and every other field are untouched. Diff is 29 insertions / 36 deletions across two files, most of it the comment recording why not to add it back.

Worth recording how this was found, because it is not a code defect. A reviewer reading a diff against the codebase cannot see how the product is operated — ops/playbook/ holds that, and nothing in the review path reads it. The earlier finding on this PR ("append leaves stale routing state → pass the overwrite flag") was correct about the code and wrong about the product, and I applied it without checking who else writes that field.

Follow-ups filed: TASK-21968 (the segments design question) and TASK-21970 (scope [limits] and [transactions] by user id, which also brings back limits_remaining and latest_activity).

Full suite green: 3635 passed across 292 suites.

@chip-peanut-bot chip-peanut-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Chip review — changes requested

Request changes: support-topic metadata has no stable open-cycle lifecycle, and an in-transit balance edge emits contradictory zero-balance context. Both prior account-scoping findings no longer apply at this head.

Findings

  • MAJOR · src/utils/crisp.ts:83 · Scope support_topic to one open cycle
    On the proxy, later snapshot pushes deliberately call setCrispUserData without the unchanged composer prefill; this full session:data write then replaces support_topic with an empty string, so a balance or verification update can erase the topic that was meant to survive composer edits. In the other direction, ModalsContext never clears its prefill, so a later generic support open can publish the previous topic. Give the topic its own per-open-cycle value and pass it to session data independently of whether message:text should be refreshed.

  • MINOR · src/hooks/useCrispUserData.ts:203 · Count in-transit funds before flagging zero balance
    buildBalanceSummary includes inTransitToCollateralCents in the displayed spendable total, but this zero test ignores it. During the smart-to-Rain handoff, smartBalance=0n, spendingPower=0, and a positive inTransitToCollateralCents produce a funded balance row together with a zero-balance segments row. Compute zero from the same display total so the sidebar cannot contradict itself.

Checked clean

  • Verified the detached worktree head, supplied base SHA, PR author, and dev base ref.
  • Earlier major finding in useCrispUserData: NO LONGER APPLIES; limits and latest activity were removed, and remaining cache reads are keyed by wallet address or user ID.
  • Earlier blocking finding in authContext: NO LONGER APPLIES; the post-render cache clear was reverted together with the unscoped observers it could not safely protect.
  • Reviewed web proxy handshake, native Crisp sink parity, account-switch isolation, route redaction, unread-badge behavior, and cache-only query behavior.
  • Exact-head CI passed unit, e2e, typecheck, eslint, format, deploy-preview, and analyze checks.
  • The current privacy disclosure includes the Crisp account-context snapshot wording before this code reaches production.
  • The pure support-context suite passed locally; other focused suites were not runnable from the detached worktree because dependencies were not installed there, while exact-head CI passed them.

Second opinion skipped: openrouter-timeout.

Exact head: 1d87998fa6f3 · Context: repo, content

Comment thread src/utils/crisp.ts
Comment thread src/hooks/useCrispUserData.ts Outdated
Two ways the topic went wrong, in opposite directions.

It was erased. A routine metadata refresh deliberately omits the composer
prefill — re-pushing it would overwrite what the user is typing — but the same
call writes the whole session:data block, so `support_topic` went back to empty.
A balance landing mid-conversation wiped the reason the user opened support.
The topic is now its own argument to setCrispUserData, defaulting to the
composer value, and the proxy passes it on every push while still guarding
`message:text`.

It also outlived its cycle. Nothing ever cleared `supportPrefilledMessage`, so
after one "contact support about X" entry point every later open — the nav
button included — reopened with X still in the composer, and reported X as the
topic. Closing the drawer now clears it, which is where a prefill's life should
have ended all along. That half was already true of the composer before this
PR; the topic row only made it visible.

Also: decide zero balance from the same total the balance row prints. During
the smart-to-collateral handoff the funds are in neither bucket and only
`inTransitToCollateralCents` accounts for them, so reading the two halves
directly put a `zero-balance` flag next to a funded balance row.

Found by Chip on #2851.

@chip-peanut-bot chip-peanut-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Chip review — changes requested

Request changes: the new support_topic row can automatically disclose a send-link bearer secret. Both earlier findings are fixed, and exact-head CI is green.

Findings

  • BLOCKING · src/utils/crisp.ts:83 · Redact send-link secrets before publishing the support topic
    Opening support from ClaimErrorView supplies window.location.href as the prefill. Claim URLs keep the bearer password in the #p= fragment, and that password deterministically derives the private claim key. This new session:data row publishes the raw prefill to Crisp immediately, before the user sends the composer; a transient NOT_FOUND state can therefore disclose an unclaimed link credential to Crisp/support. Strip fragments and sensitive query parameters before materializing support_topic, or pass a sanitized claim identifier instead.

Checked clean

  • Earlier major finding (scope support_topic to one open cycle): FIXED; close now clears the prefill, while metadata refreshes preserve the topic during the active cycle.
  • Earlier minor finding (count in-transit funds before zero-balance): FIXED; the segment now uses computeDisplaySpendable and has an in-transit regression test.
  • Correctness: reviewed authenticated cache ownership, unavailable-vs-zero balance handling, live cache refreshes, topic lifecycle, and native/web/proxy field parity.
  • Security: reviewed postMessage source/origin guards, identity isolation, route redaction, linked-account minimization, and support-prefill trust boundaries.
  • Legal disclosure context: mono privacy content includes the widened Crisp account-context disclosure.
  • Exact-head CI is green: unit, typecheck, eslint, format, e2e, analyze, review, and deploy-preview checks succeeded.
  • Local targeted Jest execution was unavailable because this detached worktree has no installed Jest binary; exact-head unit CI succeeded instead.
  • Slop pass: no additional actionable duplication, dead code, misleading naming, or architecture drift survived verification.

Second opinion skipped: openrouter-timeout.

Exact head: 18e76b422b42 · Context: repo, product

Comment thread src/utils/crisp.ts
BLOCKING. A claim link is /claim?c=...&v=...&i=...#p=<password> (history.utils),
and that fragment is a bearer credential - it derives the private claim key, so
whoever holds it can take the funds. ClaimErrorView hands support
window.location.href, fragment included.

Before this PR that URL only sat in the composer, where the user chose whether
to send it. The support_topic row changed that: the app publishes it to Crisp
the moment the drawer opens. So a transient NOT_FOUND on a claim page disclosed
an unclaimed link's credential to a third party with no user action at all.

Redacted at the setter, not at either sink. Both paths - the composer and the
topic row - read the same stored prefill, and every call site goes through
openSupportWithMessage, so one place covers both and covers call sites nobody
has written yet. not-found.tsx already carries a comment about exactly this
hazard and sends a bare pathname, and two later call sites (ClaimErrorView,
Error.validation.view) were added passing the full href anyway. Per-call-site
discipline was tried and drifted.

Only the fragment goes. The query identifies the deposit on-chain and is what
lets an agent actually find the link, so stripping it would trade a real
security fix for a support regression.

Two tests, both failing without the fix: the redactor keeps ?c=&v=&i= while
dropping #p=, and the context never stores a password whatever the call site
passes.

Found by Chip on #2851.

@chip-peanut-bot chip-peanut-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Chip review — changes requested

The three earlier findings are fixed, but the web proxy keeps publishing the widened account snapshot after support closes.

Findings

  • BLOCKING · src/components/Global/SupportDrawer/index.tsx:80 · Stop publishing account snapshots after support closes
    After the first web open, the proxy iframe remains mounted, and this effect forwards every later userData change without checking isSupportModalOpen. For example, after the user closes support, a connectivity change or auth-context rerender can pick up a new cached balance/card overview and post that widened financial and verification snapshot to Crisp while chat is closed. This contradicts the privacy disclosure that the snapshot is shared only when support chat is opened. Gate proxy data updates to the current open cycle (while clearing support_topic separately on close), or stop/unmount the proxy when closed.

  • MAJOR · src/utils/support-context.ts:335 · [moonshotai/kimi-k3] redactSupportText keeps query-string credentials; only fragments are stripped
    ModalsContext routes every support prefill through redactSupportText, and call sites hand it window.location.href (ClaimErrorView, Error.validation.view per the in-module comment). The regex https?:\/\/\S+ only cuts at '#', so any credential living in the query survives into the composer text and the support_topic row that the app pushes to Crisp servers before the user sends anything. The module's own comment concedes this class of secret exists ("arbitrary pages can carry magic-link and OAuth tokens in the query too") but relies on individual call sites like not-found.tsx to pre-redact to a bare pathname — a new or existing call site passing href off an OAuth callback (?code=) or magic-link (?token=) URL leaks a bearer credential to Crisp. Fix: parse each matched URL and, unless the origin+path matches an explicit whitelist (e.g. /claim, whose c/v/i params an agent needs), drop the query and keep origin+pathname only; otherwise strip the query universally except whitelisted params.

Checked clean

  • Earlier blocking finding fixed: openSupportWithMessage now strips URL fragments before claim-page support text reaches the composer or support_topic.
  • Earlier major finding fixed: closing the drawer clears the prefill, and the next open writes an empty support_topic.
  • Earlier minor finding fixed: zero-balance uses computeDisplaySpendable, including inTransitToCollateralCents.
  • Cache readers are user-scoped, remain fetch-free, and suppress cached account data after authentication expires.
  • Web, proxy, and native session fields share one definition; native no longer calls the unimplemented sendMessage path or writes stale Crisp segments.
  • Exact-head CI: typecheck, unit, eslint, e2e, format, review, and analyze succeeded; report and Deploy-Preview were still in progress when checked.
  • Mono privacy disclosure says the account-context snapshot is shared only when support chat is opened.

Second opinion by moonshotai/kimi-k3: 1 finding(s), marked with the model name. It reads the diff only, so treat its findings as advice.

Exact head: 455883244b06 · Context: repo, product

Comment thread src/components/Global/SupportDrawer/index.tsx
Comment thread src/utils/support-context.ts Outdated
… query secrets

Two findings, both about data leaving without the user asking.

The proxy pushed after close. The iframe stays mounted once support has been
opened, and the update effect forwarded every later userData change without
checking whether the drawer was open. A connectivity flip or an auth re-render
therefore posted a fresh balance, card and verification snapshot to Crisp with
the chat closed. That is not a performance detail: the privacy policy this PR
ships alongside says the snapshot is shared only when the user opens support
chat, and this made that sentence untrue. Gated on isSupportModalOpen. The last
push of a cycle stands until the next open replaces it, which is that
conversation's own context and correct to leave.

The redactor only cut fragments. That covered the claim password but not the
other place a credential hides: ?code= on an OAuth callback, ?token= on a magic
link. not-found.tsx already sends a bare pathname for exactly that reason, and
its comment names the class. Call sites hand over window.location.href, so the
next one added off such a page would have leaked a bearer credential to Crisp
before the user sent anything.

The query is now dropped by default and kept only for paths known to identify
rather than authorize - /claim, whose c/v/i locate the deposit on-chain and are
what let an agent find the link at all. A deny-list of secret-looking parameter
names was the alternative and was rejected: it has to be right about every page
that exists now and every one added later. This is wrong in the safe direction,
so an unlisted page loses context, never a credential.

Both tests verified to fail without their fix. The closed-drawer one needed
rewriting first: the naive version opened nothing, so the iframe never mounted
and it passed either way - it now opens, closes, then lands a balance.

@chip-peanut-bot chip-peanut-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Chip review — changes requested

The send-link secret finding is fixed, but the earlier closed-drawer publication blocker remains reachable through proxy initialization.

Findings

  • BLOCKING · src/components/Global/SupportDrawer/index.tsx:340 · Gate proxy initialization after support closes
    STILL PRESENT from the prior review. If a logged-in user opens support while the Crisp token is resolving, closes it, and the token then resolves, hasBeenOpened still allows the hidden iframe to mount. Its CRISP_PROXY_REQUEST_INIT reaches this unconditional reply, which publishes the latest account snapshot after the drawer closed despite the open-only disclosure. Guard the init reply with the current open state (using a ref because this listener is registered once), or defer proxy mount/boot until the next open; cover close-before-token-resolution and closed remounts in the test.

Checked clean

  • Earlier send-link secret finding: FIXED; support topics are redacted before storage, claim fragments are stripped, and non-claim queries are dropped.
  • Cache-only snapshot reads use user-scoped balance and card keys, require authenticated state, and watch cache updates only while support is open.
  • Account-switch isolation, native open-cycle cancellation, sink field parity, counterparty-safe route normalization, and unknown-balance handling were reviewed.
  • The widened Crisp disclosure is present on mono origin/main.
  • Exact-head CI completed successfully: analyze, eslint, typecheck, e2e, unit, format, preview, and aggregate gates are green; git diff --check is clean.

Second opinion skipped: openrouter-timeout.

Exact head: 216513a39fa3 · Context: repo, mono

Comment thread src/components/Global/SupportDrawer/index.tsx
Gating the update effect covered one of two ways the snapshot leaves. Two more
paths reached Crisp with the chat closed.

The iframe could first mount while closed. hasBeenOpened latched on the drawer
alone, but the iframe is also gated on the token being ready - so opening
support while a logged-in user's token resolves, then closing, left the latch
true and mounted the hidden iframe when the token landed. It booted, handshook,
and published outside an open cycle. The latch now needs both.

The handshake reply answered unconditionally. A token or locale change remounts
an already-mounted iframe via its key, and that can happen with the drawer shut;
the fresh proxy asks for its payload and got it. The listener is registered once
so it cannot close over the open state - it reads a ref instead.

Staying silent is safe rather than fatal: the proxy re-asks every 250ms until it
boots, and the update effect posts the payload the moment the drawer opens,
which boots it directly. If the 8s watchdog fires first the parent shows the
mail fallback and the later CRISP_READY clears it.

Both tests verified failing without their fix - which took three attempts and is
worth recording. The first never mounted the iframe, so nothing posted either
way. The second built a MessageEvent whose source jsdom drops, so the identity
check skipped the branch; the file's own requestInit helper sets it via
defineProperty. The third looked right but prettier had folded the guard into
'if (...) return // comment', so the probe that deleted it never matched and the
guard was present the whole time. A test that cannot fail proves nothing.

@chip-peanut-bot chip-peanut-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Chip review — changes requested

Both earlier closed-support blockers are fixed, but the current head still has a credential-redaction hole and two support snapshot lifecycle defects.

Findings

  • BLOCKING · src/utils/support-context.ts:354 · Allowlist the claim query before publishing it
    The /claim exemption keeps url.search wholesale, although only c, v, and i are declared safe. A claim URL containing an extra credential-bearing parameter or an encoded nested link therefore retains that credential after the fragment is stripped, and ClaimErrorView publishes the result to Crisp as soon as support opens. Rebuild the retained query from an explicit safe-parameter allowlist (and add a case with an unsafe extra/nested parameter) instead of forwarding the original search string.

  • MAJOR · src/hooks/useCrispUserData.ts:93 · Close the cache subscription setup race
    Opening support reads the cache during render and subscribes only in a passive effect. If the in-flight balance or card query resolves after that render but before this effect attaches, its only updated event is missed and the conversation keeps the initial unavailable snapshot and routing flag until some unrelated render or later cache event. Re-read once immediately after subscribing, or use a subscription primitive that rechecks the snapshot after registration; cover the update-between-render-and-effect case.

  • MAJOR · src/app/crisp-proxy/page.tsx:39 · Clear the old web composer on reopen
    Closing support now clears the context prefill, but the iframe is intentionally kept mounted and this update passes an empty prefill to setCrispUserData, whose truthy guard emits no message:text command. After opening support from an error CTA, closing without sending, and reopening from navigation, Crisp still shows the previous error text in the composer even though the new open cycle has no prefill. When prefillChanged is true and the next value is empty, explicitly set the composer to an empty string; keep routine metadata refreshes untouched.

Checked clean

  • Exact worktree HEAD and merge base match the supplied head and base SHAs.
  • Earlier account-snapshot push after close: FIXED by the open-state gate and regression test.
  • Earlier proxy initialization after close: FIXED by token-ready latching plus the current-open handshake gate and regression tests.
  • Exact-head format, typecheck, eslint, unit, e2e, analyze, preview, and aggregate CI checks completed successfully.
  • Cache readers remain fetch-free and only use identity-scoped balance and card keys; unscoped activity and limits fields were removed.
  • Canonical privacy content contains the disclosed account-context wording; production publication order remains outside this code head.
  • Crisp web SDK behavior for message:text was checked against the provider documentation.

Second opinion skipped: openrouter-timeout.

Exact head: c566cc5f67bc · Context: repo, product

Comment thread src/utils/support-context.ts Outdated
Comment thread src/hooks/useCrispUserData.ts Outdated
Comment thread src/app/crisp-proxy/page.tsx
…e subscribe race

Three findings, all confirmed against the code first.

Naming the path was not enough. The /claim exemption forwarded url.search
wholesale, so anything else riding on a claim URL - a parameter added later, an
encoded nested link - survived the fragment strip and reached Crisp as soon as
support opened. Only c, v and i were ever vetted, and the query is now rebuilt
from those names, so an unvetted parameter is dropped rather than published.

The composer kept the previous cycle's text. The iframe stays mounted between
opens, and clearing the context prefill made the next payload carry an empty
string - which a truthy guard skipped. Open support from an error CTA, close
without sending, reopen from the nav, and the old error text was still in the
box. undefined now means leave the composer alone (a routine metadata refresh
must not overwrite typing); an empty string is a real instruction to clear it.
Same distinction applied to the proxy's anonymous branch.

The cache subscription could miss its only event. Values are read during
render and the subscription attaches in a passive effect, so a balance or card
query resolving in that window emitted its update with nobody listening, and
the conversation kept the initial unavailable snapshot - and the routing flag
derived from it. The effect now re-reads once after attaching.

Each test verified failing without its own fix, restored between probes. The
race test uses a sibling layout effect, which lands exactly in the window:
React runs layout effects after render and before passive effects.

1550 tests across 103 suites green.

@chip-peanut-bot chip-peanut-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Chip review — no blocking findings — this is not an approval

The four earlier findings are fixed, and no actionable defect remains at this exact head.

Checked clean

  • Earlier claim-query finding: FIXED; /claim now rebuilds the query from the explicit c/v/i allowlist and tests drop extra and nested parameters.
  • Earlier cache-subscription race: FIXED; the hook re-reads immediately after attaching the cache listener and covers the render-to-effect update window.
  • Earlier stale web composer finding: FIXED; an empty prefill now emits message:text with an empty string while routine metadata refreshes still leave typed text alone.
  • Earlier closed-drawer proxy initialization finding: FIXED; token-ready mounting and handshake replies both require an open support cycle, with close-before-token and closed-handshake regressions covered.
  • Correctness, security, adversarial, and slop passes covered cache ownership, account switching, balance unknown/zero handling, native open-cycle cancellation, support-topic redaction, route normalization, and field parity across Crisp sinks.
  • The canonical privacy text discloses the account-context snapshot as shared only when support chat is opened.
  • Exact-head CI is green for unit, e2e, typecheck, eslint, format, analyze, preview, review, report, and aggregate gates; git diff --check is clean. Local targeted Jest execution was unavailable because this detached worktree has no installed jest binary.

Second opinion skipped: openrouter-timeout.

Exact head: 889717db810b · Context: repo, mono

@innolope-dev
innolope-dev merged commit 2cd80fd into dev Aug 28, 2026
17 checks passed

This branch was successfully deployed

1 active deployment
Preview — 889717db Deployed Aug 28, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant