Skip to main content

Discord Activity Integration — Design Spec

Date: 2026-04-08 (updated 2026-04-09) Target: examples/test-player-map/ Status: Working in production


Context

examples/test-player-map/ is a Vite + React + TypeScript app that consumes the player-map npm package. It uses Privy (@privy-io/react-auth) as its sole authentication layer. The goal is to add Discord Activity support so the app can run:

  • In standalone mode (browser, unchanged behavior)
  • In Discord Activity mode (iframe inside a voice/text channel)

Architecture

Mode Detection

URL contains frame_id + instance_id  →  Discord Activity mode
URL contains ?session=TOKEN → External wallet login page
Otherwise → Standalone mode

isDiscordActivity() in src/utils/discordEnv.ts performs the Discord check.

Provider Tree

Discord Activity mode:

main.tsx
└─ [fetch monkey-patch: redirect external API calls through /.proxy/]
└─ DiscordProvider (Discord SDK init, OAuth, context)
└─ App
└─ PrivyWalletProvider
└─ PlayerMapView (Discord wallet flow)

External wallet login page (?session=TOKEN):

main.tsx
└─ App
└─ PrivyWalletProvider
└─ WalletLoginPage (Privy login → POST /session)

Standalone mode (unchanged):

main.tsx
└─ App
└─ PrivyWalletProvider
└─ PlayerMapView (Privy wallet flow)

Wallet Connection Flow (Discord mode)

Discord's iframe blocks Privy's popup-based login. Wallet connection uses an external page:

1. User clicks "Connect Wallet" in Discord iframe
2. UUID token generated → stored in localStorage
3. Iframe fetches /.proxy/external-url → gets EXTERNAL_URL (Cloudflare tunnel URL)
4. QR code + "Open in browser" shows: {EXTERNAL_URL}/?session={token}
5. User opens URL in browser → WalletLoginPage renders
6. User logs in with Privy → wallet address obtained
7. POST /session { token, address } → server stores it (in-memory, 10min TTL)
8. Discord iframe polls /.proxy/session/:token every 2s
9. Server returns { address } → iframe sets discordAddress → renders map

CSP Constraint (Critical)

Discord's iframe enforces a strict CSP: only connections to *.discordsays.com are allowed. This blocks:

  • https://mainnet.intuition.sh/v1/graphql (Intuition GraphQL API)
  • https://rpc.intuition.systems (Ethereum RPC)
  • wss://relay.walletconnect.org (WalletConnect — not used in Discord mode)

Fix: Monkey-patch window.fetch in main.tsx before React renders, redirecting blocked URLs through /.proxy/:

https://mainnet.intuition.sh/v1/graphql  →  /.proxy/graphql
https://rpc.intuition.systems → /.proxy/rpc

The server (unified-server.ts) proxies these to the real endpoints.


Files

New Files

FilePurpose
src/utils/discordEnv.tsisDiscordActivity() detection
src/providers/DiscordProvider.tsxDiscord SDK init, OAuth flow, React context
src/components/DiscordBadge.tsxVisual badge (Discord-only)
src/components/WalletLoginPage.tsxExternal wallet login page (Privy → POST /session)
api/server.tsExpress: OAuth token exchange + session store + proxy routes (dev mode)
api/unified-server.tsExpress: serves dist/ + all API routes (production mode)

Modified Files

FileChange
src/main.tsxConditional DiscordProvider + fetch monkey-patch for CSP bypass
src/App.tsxRoutes to WalletLoginPage when ?session=TOKEN, adds DiscordBadge
src/components/PlayerMapView.tsxFull Discord wallet flow (session polling, discordWalletProxy, discordPublicClient)
vite.config.jsProxy ALL /.proxy/*http://localhost:3001 (was only /.proxy/api)
package.jsonAdded deps + dev:api and serve:discord scripts

Server Endpoints

MethodPathPurpose
POST/tokenDiscord OAuth code → access_token
POST/sessionStore { token, address } (10min TTL)
GET/session/:tokenRetrieve address for token
GET/external-urlReturns EXTERNAL_URL env var
*/graphqlProxy → https://mainnet.intuition.sh/v1/graphql
POST/rpcProxy → https://rpc.intuition.systems

PlayerMapView Discord Specifics

  • discordPublicClient: Static viem client, transport /.proxy/rpc, getChainId() hardcoded to 1155 (bypasses wagmi state — no active connector in Discord mode)
  • discordWalletProxy: Minimal wallet client object from discordAddress; writeContract and signTypedData throw (transactions require browser signing)
  • discordUseAccount: Mock wagmi useAccount hook returning chain: intuitionMainnet, isConnected: true

Environment Variables

Client-side (.env)

VITE_PRIVY_APP_ID=
VITE_DISCORD_CLIENT_ID=
VITE_WALLETCONNECT_PROJECT_ID=

Server-side (process env / shell)

DISCORD_CLIENT_ID=
DISCORD_CLIENT_SECRET=
EXTERNAL_URL=https://xxx.trycloudflare.com # Cloudflare tunnel URL
PORT=5173 # unified-server only

Dev Workflow

Mode dev (Vite + API séparés) :

# Terminal 1
EXTERNAL_URL=https://xxx.trycloudflare.com npm run dev:api

# Terminal 2
npm run dev

# Terminal 3
cloudflared tunnel --url http://localhost:5173

Mode production (recommandé pour Discord Activity) :

npm run build
EXTERNAL_URL=https://xxx.trycloudflare.com npm run serve:discord
# + cloudflared tunnel --url http://localhost:5173

Constraints

  • Standalone mode works exactly as before — no regression
  • All Discord code conditional on isDiscordActivity() or ?session= param
  • DISCORD_CLIENT_SECRET stays server-side only
  • Transactions not supported in Discord (proxy wallet throws) — read-only mode only
  • EXTERNAL_URL must be set at server startup; it changes with each Cloudflare tunnel run

Known Limitations

  • In-memory session store → sessions lost on server restart
  • Cloudflare tunnel URL changes each run → must update Discord Activity URL mapping in portal
  • WalletConnect blocked by CSP (not a problem — Privy is used instead on external page)
  • Discord Activity URL mapping in portal must point to current Cloudflare tunnel URL