Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 137 additions & 0 deletions app.vue
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,113 @@ useHead({
})

const isMenuVisible = ref(true)

// ---------------------------------------------------------------------------
// HelpScout Beacon
//
// Beacon renders its launcher and panel inside a cross-origin iframe, so our
// CSS cannot reach inside it — appearance is driven entirely through its own
// config API, which accepts repeated calls at runtime.
// ---------------------------------------------------------------------------

const BEACON_MOBILE_BREAKPOINT = 900
// The dark-mode SVG filter maps Help Scout's white canvas to --bg-body and
// dark text to white. This source colour becomes the app's #23c09b accent.
const BEACON_DARK_FILTER_ACCENT = '#e34472'

/**
* Read a theme token straight off the document so Beacon tracks the app's
* palette instead of a hardcoded copy that can drift out of sync.
* Beacon only accepts a hex string, so anything else falls back.
*/
const themeToken = (name: string, fallback: string) => {
if (!import.meta.client) return fallback
const value = getComputedStyle(document.documentElement).getPropertyValue(name).trim()
return /^#(?:[0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i.test(value) ? value : fallback
}

/**
* --accent-600 is the app's primary-button green (#23c09b in both themes) and
* the colour Beacon paints its launcher, panel header and send button with.
* --accent-500 is deliberately not used here: it is the brighter text accent,
* and it made the launcher louder than every button in the app.
*/
const beaconAccent = computed(() => {
// Depend on theme so the token is re-read after a theme switch.
void theme.value
return themeToken('--accent-600', '#23c09b')
})

const applyBeaconDesign = () => {
if (!import.meta.client || typeof window.Beacon !== 'function') return

// On mobile the bottom nav occupies ~98px, so lift the launcher clear of it.
const isMobile = window.innerWidth <= BEACON_MOBILE_BREAKPOINT
const verticalOffset = isMobile && isMenuVisible.value ? 106 : 24

window.Beacon('config', {
// Use the pre-filter source colour in dark mode so Beacon's rendered
// accent still matches the rest of the app.
color: theme.value === 'dark' ? BEACON_DARK_FILTER_ACCENT : beaconAccent.value,
display: {
style: 'icon',
iconImage: 'question',
position: 'right',
horizontalOffset: 24,
verticalOffset,
// Above page content, below UiModal (3000) so dialogs are never covered.
zIndex: 2500,
},
labels: {
whatMethodWorks: 'Euler Finance',
messageButtonLabel: 'Create new support ticket',
noTimeToWaitAround: '',
responseTime: 'Your wallet address will be attached to the ticket. We’ll investigate the issue as soon as possible.',
},
})
}

/** Wallet + chain + console buffer, attached to the conversation for agents. */
const applyBeaconSessionData = () => {
if (!import.meta.client || typeof window.Beacon !== 'function') return
window.Beacon('session-data', {
'Wallet address': address.value ?? 'Not connected',
'Chain': String(chainId.value),
'App state': JSON.stringify({
url: window.location.href,
route: route.name,
theme: theme.value,
viewport: `${window.innerWidth}x${window.innerHeight}`,
userAgent: navigator.userAgent,
}),
'Recent console output': getRecentConsoleOutput() || 'none captured',
})
}

watch([theme, address, isMenuVisible], applyBeaconDesign, { immediate: true })
watch([address, chainId], applyBeaconSessionData, { immediate: true })

// Keep the launcher off the onboarding (connect wallet) screen. Beacon injects
// its container after window load, so this toggles a root class that
// assets/styles/main.scss keys off, rather than the element itself.
watch(() => route.name, (name) => {
if (!import.meta.client) return
document.documentElement.classList.toggle('beacon-hidden', name === 'onboarding')
}, { immediate: true })

onMounted(() => {
applyBeaconDesign()
// Re-snapshot diagnostics when the panel is opened so the console buffer and
// app state reflect the moment the user decided to ask for help.
if (typeof window.Beacon === 'function') {
window.Beacon('on', 'open', applyBeaconSessionData)
}
window.addEventListener('resize', applyBeaconDesign)
})
onUnmounted(() => {
if (import.meta.client) window.removeEventListener('resize', applyBeaconDesign)
})

const isHeaderVisible = ref(true)
let interval: NodeJS.Timeout | null = null

Expand Down Expand Up @@ -203,6 +310,36 @@ onUnmounted(() => {
</script>

<template>
<!-- Help Scout is cross-origin. This filter maps its white interface to the
same #08131f value used by --bg-body in the app's dark theme. -->
<svg
aria-hidden="true"
class="absolute w-0 h-0 overflow-hidden"
focusable="false"
>
<filter
id="euler-beacon-dark-theme"
color-interpolation-filters="sRGB"
>
<feComponentTransfer>
<feFuncR
type="linear"
slope="-0.968627"
intercept="1"
/>
<feFuncG
type="linear"
slope="-0.92549"
intercept="1"
/>
<feFuncB
type="linear"
slope="-0.878431"
intercept="1"
/>
</feComponentTransfer>
</filter>
</svg>
<div
class="sticky top-0 z-[101]"
>
Expand Down
18 changes: 18 additions & 0 deletions assets/styles/main.scss
Original file line number Diff line number Diff line change
Expand Up @@ -69,3 +69,21 @@ body {
.auto-link strong {
font-weight: 600;
}

// HelpScout Beacon is injected asynchronously after window load (see the
// helpscout-beacon head script in nuxt.config.ts), so per-route visibility
// is driven by this root class (toggled in app.vue) instead of the element.
html.beacon-hidden #beacon-container {
display: none;
}

// Beacon's UI is rendered in a cross-origin iframe, so its white card
// surfaces cannot be styled from the host app. The SVG component-transfer
// filter maps white to the dark --bg-body value (#08131f) and dark text to
// white, preserving contrast in every Help Scout screen.
//
// The Beacon source accent is pre-compensated in app.vue so it still renders
// as the app's green after the filter is applied.
html[data-theme="dark"] #beacon-container iframe {
filter: url('#euler-beacon-dark-theme');
}
10 changes: 10 additions & 0 deletions nuxt.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ import { lstatSync } from 'node:fs'
import { resolve } from 'node:path'

const themeBootstrapScript = '(function(){var theme="dark";try{theme=localStorage.getItem("theme")==="light"?"light":"dark"}catch(e){}document.documentElement.setAttribute("data-theme",theme);document.documentElement.style.colorScheme=theme})()'
// HelpScout Beacon loader (official embed snippet, minified) + init call.
// Defers the actual https://beacon-v2.helpscout.net download until window load.
// Executes under CSP via the per-request nonce injected by server/plugins/csp.ts;
// 'strict-dynamic' then trusts the beacon script it inserts.
const helpScoutBeaconScript = '!function(e,t,n){function a(){var e=t.getElementsByTagName("script")[0],n=t.createElement("script");n.type="text/javascript",n.async=!0,n.src="https://beacon-v2.helpscout.net",e.parentNode.insertBefore(n,e)}if(e.Beacon=n=function(t,n,a){e.Beacon.readyQueue.push({method:t,options:n,data:a})},n.readyQueue=[],"complete"===t.readyState)return a();e.attachEvent?e.attachEvent("onload",a):e.addEventListener("load",a,!1)}(window,document,window.Beacon||function(){});window.Beacon("init","29adfc12-af7e-46bc-8bfa-c3eb13225889")'
const eulerSdkPackage = '@eulerxyz/euler-v2-sdk'
const chartPackages = ['chart.js', 'chartjs-plugin-annotation']
const isLinkedEulerSdk = (() => {
Expand Down Expand Up @@ -54,6 +59,11 @@ export default defineNuxtConfig({
tagPosition: 'head',
tagPriority: 'critical',
},
{
id: 'helpscout-beacon',
innerHTML: helpScoutBeaconScript,
tagPosition: 'bodyClose',
},
],
meta: [
{
Expand Down
8 changes: 8 additions & 0 deletions plugins/00.console-capture.client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/**
* Installs the console ring buffer as early as possible (00. prefix sorts
* before the wagmi plugin) so boot-time warnings/errors are captured for
* the HelpScout support diagnostics. See utils/console-capture.ts.
*/
export default defineNuxtPlugin(() => {
installConsoleCapture()
})
17 changes: 13 additions & 4 deletions server/plugins/csp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,12 @@ const CONNECT_SRC_BASE = [
'wss://www.walletlink.org',
'wss://relay.walletconnect.com',
'wss://relay.walletconnect.org',
// HelpScout Beacon (https://docs.helpscout.com/article/815-csp-settings-for-beacon)
'https://beaconapi.helpscout.net',
'https://chatapi.helpscout.net',
'https://d3hb14vkzrxvla.cloudfront.net',
'https://sockjs-helpscout.pusher.com',
'wss://*.pusher.com',
]

export function buildCsp(
Expand All @@ -179,18 +185,21 @@ export function buildCsp(
const directives = [
'default-src \'self\'',
`script-src 'self' 'nonce-${nonce}' 'strict-dynamic' 'wasm-unsafe-eval' https://static.cloudflareinsights.com`,
'style-src \'unsafe-inline\' \'self\'',
// beacon-v2.helpscout.net: HelpScout Beacon loads its stylesheet, fonts,
// notification sounds, and chat iframe from there. object-src stays 'none' —
// the Beacon iframe is covered by frame-src.
'style-src \'unsafe-inline\' \'self\' https://beacon-v2.helpscout.net https://fonts.googleapis.com',
'object-src \'none\'',
'base-uri \'self\'',
`connect-src ${connectSrc.join(' ')}`,
'font-src \'self\' https://fonts.reown.com',
'frame-src \'self\' https://verify.walletconnect.org https://verify.walletconnect.com',
'font-src \'self\' data: https://fonts.reown.com https://fonts.gstatic.com https://beacon-v2.helpscout.net',
'frame-src \'self\' https://verify.walletconnect.org https://verify.walletconnect.com https://beacon-v2.helpscout.net',
'frame-ancestors \'none\'',
// Token logos come from arbitrary CDNs (CoinGecko, DefiLlama, Uniswap, etc.)
// that cannot be whitelisted upfront. Images are passive content — no script execution risk.
'img-src \'self\' data: blob: https:',
'manifest-src \'self\'',
'media-src \'self\'',
'media-src \'self\' https://beacon-v2.helpscout.net',
'worker-src \'self\' blob:',
'form-action \'self\'',
...(isDev ? [] : ['upgrade-insecure-requests']),
Expand Down
9 changes: 9 additions & 0 deletions tests/server/security.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,15 @@ describe('buildCsp', () => {
.find(d => d.startsWith('connect-src'))
expect(connectSrc).toContain('https://api.4byte.sourcify.dev')
})

it('allows HelpScout Beacon API and chat traffic', () => {
const connectSrc = csp
.split(';')
.map(d => d.trim())
.find(d => d.startsWith('connect-src'))
expect(connectSrc).toContain('https://beaconapi.helpscout.net')
expect(connectSrc).toContain('https://chatapi.helpscout.net')
})
})

describe('applySecurityHeaders', () => {
Expand Down
51 changes: 51 additions & 0 deletions tests/utils/console-capture.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { describe, it, expect, beforeAll } from 'vitest'
import { installConsoleCapture, getRecentConsoleOutput } from '~/utils/console-capture'

describe('console-capture', () => {
beforeAll(() => {
installConsoleCapture()
})

it('captures console output with level and timestamp', () => {
console.log('hello capture')

Check warning on line 10 in tests/utils/console-capture.test.ts

View workflow job for this annotation

GitHub Actions / lint

Unexpected console statement. Only these console methods are allowed: warn, error
const output = getRecentConsoleOutput()
expect(output).toMatch(/\[log\] hello capture/)
expect(output).toMatch(/\d{4}-\d{2}-\d{2}T/)
})

it('serializes non-string arguments', () => {
console.warn('state', { chainId: 1, ok: true })
expect(getRecentConsoleOutput()).toContain('[warn] state {"chainId":1,"ok":true}')
})

it('redacts query-string values so URL-borne API keys never reach a ticket', () => {
console.error('rpc failed https://eth-mainnet.alchemy.com/v2/data?apiKey=supersecret123&x=1')
const output = getRecentConsoleOutput()
expect(output).not.toContain('supersecret123')
expect(output).toContain('?apiKey=[redacted]')
})

it('survives circular structures without throwing', () => {
const circular: Record<string, unknown> = {}
circular.self = circular
expect(() => console.log(circular)).not.toThrow()

Check warning on line 31 in tests/utils/console-capture.test.ts

View workflow job for this annotation

GitHub Actions / lint

Unexpected console statement. Only these console methods are allowed: warn, error
expect(getRecentConsoleOutput()).toContain('[object Object]')
})

it('truncates individual entries', () => {
console.log('x'.repeat(5000))

Check warning on line 36 in tests/utils/console-capture.test.ts

View workflow job for this annotation

GitHub Actions / lint

Unexpected console statement. Only these console methods are allowed: warn, error
const line = getRecentConsoleOutput().split('\n').find(l => l.includes('xxx'))
expect(line).toBeDefined()
expect(line!.length).toBeLessThan(500)
})

it('caps total output below the HelpScout session-data value limit', () => {
for (let i = 0; i < 200; i++) {
console.log(`filler line ${i} ${'y'.repeat(300)}`)

Check warning on line 44 in tests/utils/console-capture.test.ts

View workflow job for this annotation

GitHub Actions / lint

Unexpected console statement. Only these console methods are allowed: warn, error
}
const output = getRecentConsoleOutput()
expect(output.length).toBeLessThanOrEqual(9000)
// newest entries win — the last line logged must be present
expect(output).toContain('filler line 199')
})
})
3 changes: 3 additions & 0 deletions types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,8 @@ declare global {
interface Window {
pw: unknown
gtag: (...args: unknown[]) => void
// HelpScout Beacon JS API. The shim in nuxt.config.ts defines this before
// the app mounts and queues calls until the real Beacon script loads.
Beacon: (method: string, options?: unknown, data?: unknown) => void
}
}
Loading
Loading