Skip to content

fix: prevent duplicate Crisp conversations with session continuity tokens#1740

Open
chip-peanut-bot[bot] wants to merge 6 commits intomainfrom
chip/fix-crisp-duplicate-sessions
Open

fix: prevent duplicate Crisp conversations with session continuity tokens#1740
chip-peanut-bot[bot] wants to merge 6 commits intomainfrom
chip/fix-crisp-duplicate-sessions

Conversation

@chip-peanut-bot
Copy link
Contributor

Problem

Same user opens Crisp support chat → gets multiple separate conversations. Example: Jacob Koch-Gallup got 2 sessions within 1 hour for the same refund issue.

Root cause: Crisp relies on browser cookies for session identity. When cookies are lost (cleared, incognito, Safari/Firefox iframe cookie partitioning), a new anonymous session is created.

Solution

Implements Crisp's Session Continuity system using CRISP_TOKEN_ID.

Generates a deterministic token from SHA-256(salt + userId) — same user always gets the same conversation, regardless of cookies or device.

Changes

File What
src/hooks/useCrispTokenId.ts New — generates stable Crisp token from userId via Web Crypto SHA-256
src/hooks/useCrispProxyUrl.ts Passes crisp_token_id as URL param to proxy page
src/components/Global/SupportDrawer/index.tsx Wires up the token hook
src/app/crisp-proxy/page.tsx Sets CRISP_TOKEN_ID before Crisp script loads (critical — must be set before init)
src/utils/crisp.ts Clears CRISP_TOKEN_ID on logout/session reset
src/types/global.d.ts Adds CRISP_TOKEN_ID and CRISP_WEBSITE_ID to Window type

How it works

  1. User opens support → useCrispTokenId computes SHA-256('peanut-crisp-session-v1:' + userId)
  2. Token passed as URL param to the crisp-proxy iframe
  3. CRISP_TOKEN_ID set on window before Crisp JS loads
  4. Crisp uses this token to find/resume the user's existing conversation
  5. On logout: token is cleared + session reset → clean slate for next user

Notes

  • Anonymous (marketing page) Crisp chat is unaffected — no token is set there
  • Token is formatted as UUID-like string per Crisp docs recommendation
  • No backend changes needed — token is derived client-side from existing userId

Thread: #duplicate-crisp-convos

…kens

Adds CRISP_TOKEN_ID support using a deterministic SHA-256 hash of the
user's ID. This ensures the same user always resumes the same Crisp
conversation, even if cookies are cleared or they switch devices.

Previously, Crisp relied solely on browser cookies for session identity.
When cookies were lost (cleared, incognito, iframe cookie partitioning
in Safari/Firefox), a new anonymous session was created — leading to
duplicate conversations for the same user.

Changes:
- New hook: useCrispTokenId — generates stable token from SHA-256(userId)
- crisp-proxy/page.tsx — sets CRISP_TOKEN_ID before Crisp script loads
- useCrispProxyUrl — passes token as URL param to proxy page
- SupportDrawer — wires up the token hook
- crisp.ts — clears CRISP_TOKEN_ID on logout/session reset
- global.d.ts — adds CRISP_TOKEN_ID and CRISP_WEBSITE_ID to Window type

Ref: https://docs.crisp.chat/guides/chatbox-sdks/web-sdk/session-continuity/
@vercel
Copy link

vercel bot commented Mar 12, 2026

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

Project Deployment Actions Updated (UTC)
peanut-wallet Ready Ready Preview, Comment Mar 13, 2026 3:45pm

Request Review

- Move token setting from useEffect into inline <Script> tag so it's
  guaranteed to be set before Crisp's l.js loads
- Cache generated tokens in memory to prevent undefined→resolved state
  change that caused iframe reloads
- Use proper Window typing instead of (window as any) casts
- Revert first useEffect deps back to [] (CRISP_RUNTIME_CONFIG is static)
@Hugo0
Copy link
Contributor

Hugo0 commented Mar 12, 2026

@coderabbitai review

Context: This PR adds CRISP_TOKEN_ID to prevent duplicate Crisp chat sessions for the same user (e.g. when cookies are cleared or they switch browsers).

Key risks to review:

  1. Session continuity for existing users: All existing users will get a new Crisp session after this ships — the token is a SHA-256 hash Crisp has never seen, so it won't link to their existing conversation history. We need a migration script (via Crisp REST API) to set token_id on existing sessions before deploying. This PR should not be merged until that migration is ready.

  2. Token timing: CRISP_TOKEN_ID must be set before Crisp's l.js loads. We moved it into the inline <Script> tag (reads from URL params synchronously) instead of a useEffect. Verify this ordering is correct.

  3. Anonymous users: When no user is logged in, no token is set — Crisp falls back to cookie-based sessions as before. Verify this doesn't break marketing/landing page support chat.

  4. Logout flow: resetCrispSession clears CRISP_TOKEN_ID = null before calling session:reset. Verify this fully unbinds the user so the next login gets the correct session.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 12, 2026

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Read all relevant files for the review
fd -e ts -e tsx "useCrispTokenId|useCrispProxyUrl|crisp" --exec echo {}

echo "=== useCrispTokenId.ts ==="
fd "useCrispTokenId.ts" --exec cat {}

echo "=== useCrispProxyUrl.ts ==="
fd "useCrispProxyUrl.ts" --exec cat {}

echo "=== SupportDrawer index.tsx ==="
fd "SupportDrawer" -t d
fd -p "SupportDrawer/index.tsx" --exec cat {}

echo "=== crisp-proxy page.tsx ==="
fd "page.tsx" -p "crisp-proxy" --exec cat {}

echo "=== crisp.ts ==="
fd "crisp.ts" -t f --exec cat {}

echo "=== global.d.ts ==="
fd "global.d.ts" --exec cat {}

Length of output: 11689


🏁 Script executed:

#!/bin/bash
echo "=== Find crisp-proxy page ==="
fd "page.tsx" | grep -i crisp

echo "=== Also try finding by directory ==="
fd -t d "crisp-proxy"

echo "=== Read crisp-proxy page ==="
find . -path "*/crisp-proxy/page.tsx" -exec cat {} \;

echo "=== constants/crisp.ts ==="
cat src/constants/crisp.ts

echo "=== Check global.d.ts for duplicate interface ==="
fd "global.d.ts" --exec cat {}

echo "=== Check how resetCrispProxySessions is called on logout ==="
rg "resetCrispProxySessions|resetCrispSession" --type ts --type tsx -n -C3

echo "=== Check how the proxy iframe handles CRISP_RESET_SESSION message ==="
rg "CRISP_RESET_SESSION|crisp_needs_reset" -n -C5

Length of output: 9763


Adds session_merge: true to CRISP_RUNTIME_CONFIG. This tells Crisp to
automatically merge messages from old cookie-based sessions into the new
token-based session when a user first opens chat after deploy.

Without this, existing users who still have Crisp cookies would get a
fresh empty session. With session_merge, their conversation history
carries over seamlessly.

Ref: https://docs.crisp.chat/guides/chatbox-sdks/web-sdk/session-continuity/#how-to-merge-messages-from-anonymous-sessions-to-token-sessions
@Hugo0
Copy link
Contributor

Hugo0 commented Mar 12, 2026

@coderabbitai review

Latest commit adds session_merge: true to CRISP_RUNTIME_CONFIG. This tells Crisp to automatically merge messages from the user's existing cookie-based session into the new token-bound session on first load after deploy — solving the migration blocker without any server-side script.

Please confirm this is safe and doesn't introduce any issues with the existing Crisp config (lock_maximized, lock_full_view, cross_origin_cookies).

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 12, 2026

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
echo "=== crisp-proxy page.tsx ==="
find . -path "*/crisp-proxy/page.tsx" -exec cat {} \;

echo "=== useCrispTokenId.ts ==="
fd "useCrispTokenId.ts" --exec cat {}

echo "=== crisp.ts utils ==="
fd "crisp.ts" -t f --exec cat {}

echo "=== CRISP_RUNTIME_CONFIG usages ==="
rg "CRISP_RUNTIME_CONFIG" -n -C5

Length of output: 12990


🌐 Web query:

Crisp chat CRISP_RUNTIME_CONFIG session_merge documentation

💡 Result:

Crisp documents CRISP_RUNTIME_CONFIG.session_merge in the Web Chat SDK → Session Continuity guide, under “How to merge messages from anonymous sessions to token sessions?”. When enabled, Crisp will merge the previous anonymous-session messages into the authenticated (token) session after you switch sessions via CRISP_TOKEN_ID / session continuity. [1]

Add this before the Crisp chatbox embed loads:

<script type="text/javascript">
  window.CRISP_RUNTIME_CONFIG = {
    session_merge: true
  };
</script>

Crisp also notes to avoid defining CRISP_RUNTIME_CONFIG in multiple places (to prevent overwriting it) and to review the security notes/limitations in that same Session Continuity doc. [1]

Sources
[1] Crisp Docs — Session Continuity (Web Chat SDK) https://docs.crisp.chat/guides/chatbox-sdks/web-sdk/session-continuity/


@Hugo0, session_merge: true is the correct tool here. Here's the full analysis:


session_merge: true — Correct and Migration Blocker Resolved

Crisp's official Session Continuity docs confirm this is the exact mechanism: "If you want to automatically merge messages from the previous anonymous session to the token session, you can opt-in for the session merge feature." This replaces the need for a server-side migration script — Crisp handles the one-time merge automatically on first load when the token is new to its servers. The migration blocker is resolved. ✅

session_merge is an independent config key with no conflicts — it coexists cleanly with lock_maximized, lock_full_view, and cross_origin_cookies. ✅


🚨 Critical: CRISP_RUNTIME_CONFIG Timing Race — session_merge May Silently Fail

The Crisp docs note: "Make sure you did not already define the CRISP_RUNTIME_CONFIG global variable somewhere else, otherwise you'll be overwriting it." More importantly, the documented pattern requires CRISP_RUNTIME_CONFIG to be defined before the Crisp embed script loads.

In the current code, CRISP_RUNTIME_CONFIG is set in a useEffect() (Line 21–29), while l.js is loaded in a separate <Script strategy="afterInteractive"> block. Both fire post-hydration with no guaranteed ordering. If the Script runs first, Crisp initializes without session_merge (and also without lock_maximized, lock_full_view, cross_origin_cookies) — and the migration merge silently doesn't happen for that page load.

This has always been a latent race for lock_maximized/lock_full_view, but session_merge makes it a functional correctness issue for deploy-day users.

Fix — move CRISP_RUNTIME_CONFIG into the same synchronous Script block, before l.js is appended:

<Script strategy="afterInteractive" id="crisp-proxy-widget">
    {`
        window.$crisp=[];
        window.CRISP_WEBSITE_ID="${CRISP_WEBSITE_ID}";
        window.CRISP_RUNTIME_CONFIG = {
            lock_maximized: true,
            lock_full_view: true,
            cross_origin_cookies: true,
            session_merge: true
        };
        (function(){
            var t=new URLSearchParams(window.location.search).get("crisp_token_id");
            if(t) window.CRISP_TOKEN_ID=t;
        })();
        (function(){
            var d=document;
            var s=d.createElement("script");
            s.src="https://client.crisp.chat/l.js";
            s.async=1;
            d.getElementsByTagName("head")[0].appendChild(s);
        })();
        window.$crisp.push(["safe", true]);
    `}
</Script>

And remove the useEffect that sets CRISP_RUNTIME_CONFIG entirely.

This guarantees CRISP_RUNTIME_CONFIG is set synchronously before l.js is ever fetched — the only safe ordering per Crisp's documentation.


✅ Logout Flow (Previously Flagged — Now Fixed)

The CRISP_RESET_SESSION postMessage handler now correctly calls window.CRISP_TOKEN_ID = null before session:reset, matching the documented logout sequence. ✅


🧠 Learnings used
Learnt from: Hugo0
Repo: peanutprotocol/peanut-ui PR: 0
File: :0-0
Timestamp: 2025-07-05T16:58:25.340Z
Learning: Hugo0 successfully refactored sessionStorage usage to React Context in the onramp flow, demonstrating preference for centralized state management over browser storage for component-shared state in React applications.

Learnt from: kushagrasarathe
Repo: peanutprotocol/peanut-ui PR: 0
File: :0-0
Timestamp: 2026-03-04T18:32:51.654Z
Learning: In peanutprotocol/peanut-ui, Bridge-specific add-money and withdraw flows (`src/app/(mobile-ui)/add-money/[country]/bank/page.tsx`, `src/components/AddWithdraw/AddWithdrawCountriesList.tsx`, `src/components/Claim/Link/views/BankFlowManager.view.tsx`) have been migrated (PR `#1713`) to use the unified `isUserKycApproved` from `useKycStatus()` hook instead of per-component `liveKycStatus` + WebSocket state. The migration is complete; no more direct `bridgeKycStatus === 'approved'` checks in those files.

Learnt from: Hugo0
Repo: peanutprotocol/peanut-ui PR: 942
File: src/components/AddMoney/consts/index.ts:2151-2162
Timestamp: 2025-06-30T10:44:08.048Z
Learning: Hugo0 often agrees with refactoring suggestions but defers implementation due to time constraints, preferring to track improvements as follow-up issues when they're part of larger architectural changes.

Support agents can now click through to PostHog person page directly
from Crisp sidebar to view session recordings and user events.
Uses the same userId that PostHog identifies users with.
@chip-peanut-bot
Copy link
Contributor Author

@coderabbitai review

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