fix: replace download with inline copy-paste setup guide, fix CSP for WASM + Nostr relays

- Replace file download with collapsible inline guide + COPY ALL button
- Guide content has bot_id/secret pre-filled from credentials
- JoinBoutPage: "COPY GUIDE + CREDENTIALS" eagerly loads guide content
- BotProfilePage: webhook/polling guide selector with copy after secret regen
- CSP: add wasm-unsafe-eval to scriptSrc (fixes Kokoro TTS WASM)
- CSP: add wss://relay.damus.io, wss://relay.nostr.band, wss://nos.lol to connectSrc

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-13 14:06:34 +00:00
co-authored by Claude Opus 4.6
parent cef9f4188f
commit 60dd6893be
3 changed files with 138 additions and 92 deletions
+61 -26
View File
@@ -106,12 +106,16 @@ const webhookTestResult = ref<{ reachable: boolean; validResponse: boolean; late
const webhookError = ref('') const webhookError = ref('')
const webhookSuccess = ref('') const webhookSuccess = ref('')
// Setup guide download // Setup guide
const showSetupGuide = ref(false) const showSetupGuide = ref(false)
const isRegenerating = ref(false) const isRegenerating = ref(false)
const regeneratedSecret = ref('') const regeneratedSecret = ref('')
const regeneratedBotId = ref('') const regeneratedBotId = ref('')
const regenError = ref('') const regenError = ref('')
const guideContent = ref('')
const guideLoading = ref(false)
const guideCopied = ref(false)
const guideMode = ref<'webhook' | 'polling'>('webhook')
const ARCHETYPES = [ const ARCHETYPES = [
'standard', 'lobster', 'sheep', 'cyborg', 'blob', 'tank', 'dog', 'cat', 'standard', 'lobster', 'sheep', 'cyborg', 'blob', 'tank', 'dog', 'cat',
@@ -290,26 +294,28 @@ async function handleRegenerateSecret() {
isRegenerating.value = false isRegenerating.value = false
} }
async function downloadGuide(mode: 'webhook' | 'polling') { async function loadGuide(mode: 'webhook' | 'polling') {
guideMode.value = mode
guideLoading.value = true
guideContent.value = ''
guideCopied.value = false
const path = mode === 'polling' ? '/docs/BOTFIGHTS-POLLING.md' : '/docs/BOTFIGHTS-WEBHOOK.md' const path = mode === 'polling' ? '/docs/BOTFIGHTS-POLLING.md' : '/docs/BOTFIGHTS-WEBHOOK.md'
const fileName = mode === 'polling' ? 'BOTFIGHTS-POLLING.md' : 'BOTFIGHTS-WEBHOOK.md'
try { try {
const res = await fetch(path) const res = await fetch(path)
let content = await res.text() let content = await res.text()
content = content.replace(/YOUR_BOT_ID/g, regeneratedBotId.value) content = content.replace(/YOUR_BOT_ID/g, regeneratedBotId.value)
content = content.replace(/YOUR_BOT_SECRET/g, regeneratedSecret.value) content = content.replace(/YOUR_BOT_SECRET/g, regeneratedSecret.value)
const blob = new Blob([content], { type: 'text/markdown' }) guideContent.value = content
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = fileName
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
} catch { } catch {
window.open(path, '_blank') guideContent.value = '# Failed to load setup guide'
} }
guideLoading.value = false
}
function copyGuide() {
navigator.clipboard.writeText(guideContent.value)
guideCopied.value = true
setTimeout(() => { guideCopied.value = false }, 2000)
} }
onMounted(async () => { onMounted(async () => {
@@ -835,11 +841,11 @@ const tierClass = (t: number) => `tier-${t}`
<div v-if="showSetupGuide" class="mt-3 border border-border bg-surface-raised/60 p-4 space-y-3"> <div v-if="showSetupGuide" class="mt-3 border border-border bg-surface-raised/60 p-4 space-y-3">
<p class="font-mono text-[10px] text-text-muted leading-relaxed"> <p class="font-mono text-[10px] text-text-muted leading-relaxed">
Download the setup guide with your credentials embedded. Get the setup guide with your credentials embedded.
This requires regenerating your bot secret (your old secret will stop working). This requires regenerating your bot secret (your old secret will stop working).
</p> </p>
<!-- Already regenerated show credentials + download --> <!-- Already regenerated show credentials + guide content -->
<template v-if="regeneratedSecret"> <template v-if="regeneratedSecret">
<div class="p-2.5 border border-neon-green/30 bg-neon-green/5"> <div class="p-2.5 border border-neon-green/30 bg-neon-green/5">
<p class="font-display font-bold text-[10px] tracking-wider text-neon-green mb-2">NEW SECRET GENERATED</p> <p class="font-display font-bold text-[10px] tracking-wider text-neon-green mb-2">NEW SECRET GENERATED</p>
@@ -850,24 +856,53 @@ const tierClass = (t: number) => `tier-${t}`
<p class="font-mono text-[9px] text-ko mt-2">Save this now. It will not be shown again after you leave this page.</p> <p class="font-mono text-[9px] text-ko mt-2">Save this now. It will not be shown again after you leave this page.</p>
</div> </div>
<!-- Guide type selector -->
<div class="flex gap-2"> <div class="flex gap-2">
<button <button
class="flex-1 py-2 bg-neon-purple/10 border border-neon-purple/40 text-neon-purple class="flex-1 py-1.5 border font-display font-bold text-[10px] tracking-wider transition-all"
font-display font-bold text-[10px] tracking-wider :class="guideMode === 'webhook'
hover:bg-neon-purple/20 transition-all" ? 'border-neon-cyan/50 text-neon-cyan bg-neon-cyan/10'
@click="downloadGuide('webhook')" : 'border-border text-text-muted hover:border-neon-cyan/30'"
@click="loadGuide('webhook')"
> >
WEBHOOK GUIDE WEBHOOK
</button> </button>
<button <button
class="flex-1 py-2 bg-neon-cyan/10 border border-neon-cyan/40 text-neon-cyan class="flex-1 py-1.5 border font-display font-bold text-[10px] tracking-wider transition-all"
font-display font-bold text-[10px] tracking-wider :class="guideMode === 'polling'
hover:bg-neon-cyan/20 transition-all" ? 'border-neon-purple/50 text-neon-purple bg-neon-purple/10'
@click="downloadGuide('polling')" : 'border-border text-text-muted hover:border-neon-purple/30'"
@click="loadGuide('polling')"
> >
POLLING GUIDE POLLING
</button> </button>
</div> </div>
<!-- Guide content -->
<div v-if="guideContent" class="border border-border bg-black/40 overflow-hidden">
<div class="flex items-center justify-between px-3 py-2 border-b border-border/50 bg-surface-raised/30">
<span class="font-display font-bold text-[9px] tracking-wider text-text-muted">
{{ guideMode === 'polling' ? 'BOTFIGHTS-POLLING.md' : 'BOTFIGHTS-WEBHOOK.md' }}
</span>
<button
class="font-display font-bold text-[9px] tracking-wider px-2 py-0.5 border transition-all"
:class="guideCopied
? 'border-neon-green/40 text-neon-green'
: 'border-border text-text-muted hover:border-neon-cyan/40 hover:text-neon-cyan'"
@click="copyGuide"
>
{{ guideCopied ? 'COPIED' : 'COPY ALL' }}
</button>
</div>
<pre class="p-3 font-mono text-[10px] text-text-secondary leading-relaxed
overflow-x-auto max-h-60 overflow-y-auto whitespace-pre-wrap break-words select-all">{{ guideContent }}</pre>
</div>
<div v-else-if="guideLoading" class="p-4 text-center">
<p class="font-mono text-[10px] text-text-muted animate-pulse">Loading...</p>
</div>
<p v-else class="font-mono text-[10px] text-text-muted text-center">
Choose webhook or polling above to view the setup guide.
</p>
</template> </template>
<!-- Not yet regenerated show button --> <!-- Not yet regenerated show button -->
@@ -882,7 +917,7 @@ const tierClass = (t: number) => `tier-${t}`
@click="handleRegenerateSecret" @click="handleRegenerateSecret"
> >
<span v-if="isRegenerating" class="w-3 h-3 border-2 border-neon-yellow/30 border-t-neon-yellow rounded-full animate-spin" /> <span v-if="isRegenerating" class="w-3 h-3 border-2 border-neon-yellow/30 border-t-neon-yellow rounded-full animate-spin" />
{{ isRegenerating ? 'REGENERATING...' : 'REGENERATE SECRET & DOWNLOAD' }} {{ isRegenerating ? 'REGENERATING...' : 'REGENERATE SECRET' }}
</button> </button>
<p class="font-mono text-[9px] text-text-muted"> <p class="font-mono text-[9px] text-text-muted">
Your current bot secret will be invalidated and replaced with a new one. Your current bot secret will be invalidated and replaced with a new one.
+75 -64
View File
@@ -527,6 +527,11 @@ async function confirmPolling() {
} }
} }
const showSetupContent = ref(false)
const setupContent = ref('')
const setupContentLoading = ref(false)
const setupContentCopied = ref(false)
function setupDocPath() { function setupDocPath() {
return connectionMode.value === 'polling' ? '/docs/BOTFIGHTS-POLLING.md' : '/docs/BOTFIGHTS-WEBHOOK.md' return connectionMode.value === 'polling' ? '/docs/BOTFIGHTS-POLLING.md' : '/docs/BOTFIGHTS-WEBHOOK.md'
} }
@@ -535,29 +540,42 @@ function setupDocName() {
return connectionMode.value === 'polling' ? 'BOTFIGHTS-POLLING.md' : 'BOTFIGHTS-WEBHOOK.md' return connectionMode.value === 'polling' ? 'BOTFIGHTS-POLLING.md' : 'BOTFIGHTS-WEBHOOK.md'
} }
async function downloadSetupGuide() { async function toggleSetupContent() {
try { showSetupContent.value = !showSetupContent.value
const res = await fetch(setupDocPath()) if (showSetupContent.value && !setupContent.value) {
let content = await res.text() setupContentLoading.value = true
content = content.replace(/YOUR_BOT_ID/g, botId.value) try {
content = content.replace(/YOUR_BOT_SECRET/g, botSecret.value) const res = await fetch(setupDocPath())
const blob = new Blob([content], { type: 'text/markdown' }) let content = await res.text()
const url = URL.createObjectURL(blob) content = content.replace(/YOUR_BOT_ID/g, botId.value)
const a = document.createElement('a') content = content.replace(/YOUR_BOT_SECRET/g, botSecret.value)
a.href = url setupContent.value = content
a.download = setupDocName() } catch {
document.body.appendChild(a) setupContent.value = '# Failed to load setup guide'
a.click() }
document.body.removeChild(a) setupContentLoading.value = false
URL.revokeObjectURL(url)
} catch {
window.open(setupDocPath(), '_blank')
} }
} }
function copyFullPrompt() { function copySetupContent() {
const docName = setupDocName() navigator.clipboard.writeText(setupContent.value)
const text = `Read ${docName} and follow the setup instructions. Here are my credentials:\nBOT_ID=${botId.value}\nBOT_SECRET=${botSecret.value}` setupContentCopied.value = true
setTimeout(() => { setupContentCopied.value = false }, 2000)
}
async function copyFullPrompt() {
if (!setupContent.value) {
try {
const res = await fetch(setupDocPath())
let content = await res.text()
content = content.replace(/YOUR_BOT_ID/g, botId.value)
content = content.replace(/YOUR_BOT_SECRET/g, botSecret.value)
setupContent.value = content
} catch { /* fall through with empty content */ }
}
const text = setupContent.value
? setupContent.value
: `Read ${setupDocName()} and follow the setup instructions.\n\nBOT_ID=${botId.value}\nBOT_SECRET=${botSecret.value}`
navigator.clipboard.writeText(text) navigator.clipboard.writeText(text)
setupGuideCopied.value = true setupGuideCopied.value = true
setTimeout(() => { setupGuideCopied.value = false }, 2000) setTimeout(() => { setupGuideCopied.value = false }, 2000)
@@ -991,20 +1009,37 @@ function handleSignOut() {
</button> </button>
</div> </div>
<!-- Download guide --> <!-- Setup guide (collapsible) -->
<div class="mb-3"> <div class="mb-3">
<button <button
class="w-full py-2.5 border-2 font-display font-bold text-sm tracking-wider transition-all" class="w-full py-2.5 border-2 font-display font-bold text-sm tracking-wider transition-all"
:class="connectionMode === 'webhook' :class="connectionMode === 'webhook'
? 'border-neon-cyan/50 text-neon-cyan hover:bg-neon-cyan/10' ? 'border-neon-cyan/50 text-neon-cyan hover:bg-neon-cyan/10'
: 'border-neon-purple/50 text-neon-purple hover:bg-neon-purple/10'" : 'border-neon-purple/50 text-neon-purple hover:bg-neon-purple/10'"
@click="downloadSetupGuide" @click="toggleSetupContent"
> >
DOWNLOAD {{ setupDocName() }} {{ showSetupContent ? 'HIDE' : 'VIEW' }} SETUP GUIDE
</button> </button>
<p class="font-mono text-[10px] text-text-muted mt-1 text-center">
Put this file in your workspace your AI reads it to build the bot <div v-if="showSetupContent" class="mt-2 border border-border bg-black/40 overflow-hidden">
</p> <div class="flex items-center justify-between px-3 py-2 border-b border-border/50 bg-surface-raised/30">
<span class="font-display font-bold text-[9px] tracking-wider text-text-muted">{{ setupDocName() }}</span>
<button
class="font-display font-bold text-[9px] tracking-wider px-2 py-0.5 border transition-all"
:class="setupContentCopied
? 'border-neon-green/40 text-neon-green'
: 'border-border text-text-muted hover:border-neon-cyan/40 hover:text-neon-cyan'"
@click="copySetupContent"
>
{{ setupContentCopied ? 'COPIED' : 'COPY ALL' }}
</button>
</div>
<div v-if="setupContentLoading" class="p-4 text-center">
<p class="font-mono text-[10px] text-text-muted animate-pulse">Loading...</p>
</div>
<pre v-else class="p-3 font-mono text-[10px] text-text-secondary leading-relaxed
overflow-x-auto max-h-80 overflow-y-auto whitespace-pre-wrap break-words select-all">{{ setupContent }}</pre>
</div>
</div> </div>
<!-- Safety callout --> <!-- Safety callout -->
@@ -1297,49 +1332,25 @@ function handleSignOut() {
<!-- Bot credentials (shown once after registration) --> <!-- Bot credentials (shown once after registration) -->
<div v-if="botSecret" class="mb-4 p-3 border border-neon-cyan/20 bg-neon-cyan/5"> <div v-if="botSecret" class="mb-4 p-3 border border-neon-cyan/20 bg-neon-cyan/5">
<p class="font-display font-bold text-[10px] tracking-wider text-neon-cyan mb-2"> <p class="font-display font-bold text-[10px] tracking-wider text-neon-cyan mb-2">
2 STEPS THEN YOU'RE FIGHTING COPY THIS TO YOUR AI
</p> </p>
<!-- Step 1: Download --> <div class="bg-bg border border-border p-2.5 font-mono text-[10px] text-text-muted leading-relaxed select-all mb-2">
<div class="flex items-start gap-2.5 mb-3"> <div>BOT_ID=<span class="text-neon-cyan">{{ botId }}</span></div>
<span class="font-display font-black text-neon-cyan text-xs mt-0.5 shrink-0">1</span> <div>BOT_SECRET=<span class="text-neon-cyan">{{ botSecret }}</span></div>
<div class="flex-1">
<p class="font-mono text-[10px] text-text-primary mb-1.5">Download {{ setupDocName() }} into your workspace</p>
<button
class="w-full py-1.5 border border-border text-[9px] font-display font-bold tracking-wider
text-text-muted hover:border-neon-cyan/40 hover:text-neon-cyan transition-all"
@click="downloadSetupGuide"
>
DOWNLOAD {{ setupDocName() }}
</button>
</div>
</div> </div>
<!-- Step 2: Tell your AI --> <button
<div class="flex items-start gap-2.5 mb-3"> class="w-full py-2 border border-border text-[10px] font-display font-bold tracking-wider
<span class="font-display font-black text-neon-cyan text-xs mt-0.5 shrink-0">2</span> hover:border-neon-cyan/40 hover:text-neon-cyan transition-all"
<div class="flex-1"> :class="setupGuideCopied ? 'text-neon-green border-neon-green/40' : 'text-text-muted'"
<p class="font-mono text-[10px] text-text-primary mb-1.5">Tell your AI this:</p> @click="copyFullPrompt"
<div class="bg-bg border border-border p-2.5 font-mono text-[10px] text-text-muted leading-relaxed select-all"> >
<p class="text-text-primary">Read {{ setupDocName() }} and follow the setup instructions. Here are my credentials:</p> {{ setupGuideCopied ? 'COPIED' : 'COPY GUIDE + CREDENTIALS' }}
<div class="mt-1"> </button>
<div>BOT_ID=<span class="text-neon-cyan">{{ botId }}</span></div>
<div>BOT_SECRET=<span class="text-neon-cyan">{{ botSecret }}</span></div>
</div>
</div>
<button
class="w-full mt-1.5 py-1.5 border border-border text-[9px] font-display font-bold tracking-wider
hover:border-neon-cyan/40 hover:text-neon-cyan transition-all"
:class="setupGuideCopied ? 'text-neon-cyan border-neon-cyan/40' : 'text-text-muted'"
@click="copyFullPrompt"
>
{{ setupGuideCopied ? 'COPIED' : 'COPY FULL PROMPT' }}
</button>
</div>
</div>
<p class="font-mono text-[9px] text-text-muted/60 leading-relaxed"> <p class="font-mono text-[9px] text-text-muted/60 leading-relaxed mt-2">
Your AI reads the guide, creates the bot, and starts it for you. Paste this into your AI (Claude, ChatGPT, etc.) it includes the full setup guide with your credentials.
Save these credentials the secret won't be shown again. Save these credentials the secret won't be shown again.
</p> </p>
</div> </div>
+2 -2
View File
@@ -54,10 +54,10 @@ app.use('*', async (c, next) => {
app.use('*', secureHeaders({ app.use('*', secureHeaders({
contentSecurityPolicy: process.env.NODE_ENV === 'production' ? { contentSecurityPolicy: process.env.NODE_ENV === 'production' ? {
defaultSrc: ["'self'"], defaultSrc: ["'self'"],
scriptSrc: ["'self'", 'blob:'], scriptSrc: ["'self'", 'blob:', "'wasm-unsafe-eval'"],
styleSrc: ["'self'", "'unsafe-inline'", 'https://fonts.googleapis.com'], styleSrc: ["'self'", "'unsafe-inline'", 'https://fonts.googleapis.com'],
imgSrc: ["'self'", 'data:', 'blob:'], imgSrc: ["'self'", 'data:', 'blob:'],
connectSrc: ["'self'", 'https://huggingface.co', 'https://*.huggingface.co', 'https://*.hf.co', 'https://cdn.jsdelivr.net'], connectSrc: ["'self'", 'https://huggingface.co', 'https://*.huggingface.co', 'https://*.hf.co', 'https://cdn.jsdelivr.net', 'wss://relay.damus.io', 'wss://relay.nostr.band', 'wss://nos.lol'],
fontSrc: ["'self'", 'https://fonts.gstatic.com'], fontSrc: ["'self'", 'https://fonts.gstatic.com'],
workerSrc: ["'self'", 'blob:'], workerSrc: ["'self'", 'blob:'],
} : undefined, } : undefined,