feat: fix setup guide download with embedded credentials + profile page regenerate secret

- downloadSetupGuide() now triggers a real file download with bot_id/secret injected
- Add POST /api/auth/regenerate-secret endpoint (JWT auth, 3/hour rate limit)
- Add "Download Setup Guide" section to BotProfilePage with secret regeneration flow
- Old secret immediately invalidated on regeneration

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-13 13:35:48 +00:00
co-authored by Claude Opus 4.6
parent 47bc753f95
commit cef9f4188f
4 changed files with 188 additions and 3 deletions
+17
View File
@@ -388,6 +388,22 @@ export function useNostr() {
return { latencyMs: data.latencyMs || 0 }
}
async function regenerateSecret(): Promise<{ botId: string; secret: string }> {
if (!pubkey.value) throw new Error('Not logged in')
const res = await authFetch('/api/auth/regenerate-secret', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
})
const data = await res.json()
if (!res.ok) {
throw new Error(data.error || 'Failed to regenerate secret')
}
return { botId: data.botId, secret: data.secret }
}
async function registerHuman(name: string, avatarSeed?: string): Promise<BotData> {
if (!pubkey.value) throw new Error('Not logged in')
@@ -547,6 +563,7 @@ export function useNostr() {
registerHuman,
updateCustomization,
updateWebhook,
regenerateSecret,
getStoredNsec,
persistKey,
logout,
+115 -1
View File
@@ -11,7 +11,7 @@ import { ensureAudioContext } from '../game/audio'
const route = useRoute()
const router = useRouter()
const { bot: nostrBot, pubkey, isLoggedIn, logout, updateCustomization, updateWebhook, fetchNostrProfile } = useNostr()
const { bot: nostrBot, pubkey, isLoggedIn, logout, updateCustomization, updateWebhook, regenerateSecret, fetchNostrProfile } = useNostr()
const botName = route.params.name as string
interface BotCustomization {
@@ -106,6 +106,13 @@ const webhookTestResult = ref<{ reachable: boolean; validResponse: boolean; late
const webhookError = ref('')
const webhookSuccess = ref('')
// Setup guide download
const showSetupGuide = ref(false)
const isRegenerating = ref(false)
const regeneratedSecret = ref('')
const regeneratedBotId = ref('')
const regenError = ref('')
const ARCHETYPES = [
'standard', 'lobster', 'sheep', 'cyborg', 'blob', 'tank', 'dog', 'cat',
'cactus', 'pizza', 'mushroom', 'shark', 'penguin', 'octopus', 'skeleton',
@@ -268,6 +275,43 @@ async function saveWebhook() {
isUpdatingWebhook.value = false
}
async function handleRegenerateSecret() {
if (isRegenerating.value) return
isRegenerating.value = true
regenError.value = ''
regeneratedSecret.value = ''
try {
const result = await regenerateSecret()
regeneratedBotId.value = result.botId
regeneratedSecret.value = result.secret
} catch (err) {
regenError.value = err instanceof Error ? err.message : 'Failed to regenerate secret'
}
isRegenerating.value = false
}
async function downloadGuide(mode: 'webhook' | 'polling') {
const path = mode === 'polling' ? '/docs/BOTFIGHTS-POLLING.md' : '/docs/BOTFIGHTS-WEBHOOK.md'
const fileName = mode === 'polling' ? 'BOTFIGHTS-POLLING.md' : 'BOTFIGHTS-WEBHOOK.md'
try {
const res = await fetch(path)
let content = await res.text()
content = content.replace(/YOUR_BOT_ID/g, regeneratedBotId.value)
content = content.replace(/YOUR_BOT_SECRET/g, regeneratedSecret.value)
const blob = new Blob([content], { type: 'text/markdown' })
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 {
window.open(path, '_blank')
}
}
onMounted(async () => {
try {
const pk = pubkey.value || ''
@@ -779,6 +823,76 @@ const tierClass = (t: number) => `tier-${t}`
</div>
</div>
<!-- Setup guide download (owner only, bots only) -->
<div v-if="isOwner && stats.archetype !== 'human' && !stats.isHuman" class="mt-4">
<button
class="w-full py-2 border border-border text-text-secondary font-display font-bold text-[10px]
tracking-wider hover:border-neon-purple/40 hover:text-neon-purple transition-all text-center"
@click="showSetupGuide = !showSetupGuide"
>
{{ showSetupGuide ? 'HIDE' : 'DOWNLOAD' }} SETUP GUIDE
</button>
<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">
Download the setup guide with your credentials embedded.
This requires regenerating your bot secret (your old secret will stop working).
</p>
<!-- Already regenerated show credentials + download -->
<template v-if="regeneratedSecret">
<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>
<div class="font-mono text-[10px] text-text-muted space-y-1">
<div>BOT_ID=<span class="text-neon-cyan select-all">{{ regeneratedBotId }}</span></div>
<div>BOT_SECRET=<span class="text-neon-cyan select-all">{{ regeneratedSecret }}</span></div>
</div>
<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 class="flex gap-2">
<button
class="flex-1 py-2 bg-neon-purple/10 border border-neon-purple/40 text-neon-purple
font-display font-bold text-[10px] tracking-wider
hover:bg-neon-purple/20 transition-all"
@click="downloadGuide('webhook')"
>
WEBHOOK GUIDE
</button>
<button
class="flex-1 py-2 bg-neon-cyan/10 border border-neon-cyan/40 text-neon-cyan
font-display font-bold text-[10px] tracking-wider
hover:bg-neon-cyan/20 transition-all"
@click="downloadGuide('polling')"
>
POLLING GUIDE
</button>
</div>
</template>
<!-- Not yet regenerated show button -->
<template v-else>
<button
class="w-full py-2 bg-neon-yellow/10 border border-neon-yellow/40 text-neon-yellow
font-display font-bold text-[10px] tracking-wider
hover:bg-neon-yellow/20 transition-all
disabled:opacity-30 disabled:cursor-not-allowed
flex items-center justify-center gap-2"
:disabled="isRegenerating"
@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" />
{{ isRegenerating ? 'REGENERATING...' : 'REGENERATE SECRET & DOWNLOAD' }}
</button>
<p class="font-mono text-[9px] text-text-muted">
Your current bot secret will be invalidated and replaced with a new one.
</p>
</template>
<p v-if="regenError" class="font-mono text-[10px] text-ko">{{ regenError }}</p>
</div>
</div>
<!-- Sign out (only if owner) -->
<div v-if="isOwner" class="mt-3 text-center flex-shrink-0">
<button
+18 -2
View File
@@ -535,8 +535,24 @@ function setupDocName() {
return connectionMode.value === 'polling' ? 'BOTFIGHTS-POLLING.md' : 'BOTFIGHTS-WEBHOOK.md'
}
function downloadSetupGuide() {
window.open(setupDocPath(), '_blank')
async function downloadSetupGuide() {
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)
const blob = new Blob([content], { type: 'text/markdown' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = setupDocName()
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
} catch {
window.open(setupDocPath(), '_blank')
}
}
function copyFullPrompt() {
+38
View File
@@ -486,3 +486,41 @@ authRouter.post('/nostr/session', rateLimit(60_000, 10), async (c) => {
bot: botData,
})
})
// Regenerate bot secret (requires JWT auth — owner only)
authRouter.post('/regenerate-secret', rateLimit(3_600_000, 3), async (c) => {
const pubkey = extractPubkeyFromAuth(c.req.header('Authorization'))
if (!pubkey) {
return c.json({ error: 'Authentication required.' }, 401)
}
const rows = await db.select({
id: schema.bots.id,
name: schema.bots.name,
webhookUrl: schema.bots.webhookUrl,
})
.from(schema.bots)
.where(eq(schema.bots.publicKey, pubkey))
.limit(1)
if (rows.length === 0) {
return c.json({ error: 'No bot found for this key.' }, 404)
}
const bot = rows[0]
const isHuman = bot.webhookUrl === 'http://human.local/'
if (isHuman) {
return c.json({ error: 'Human players do not use bot secrets.' }, 400)
}
const secret = randomBytes(32).toString('hex')
await db.update(schema.bots)
.set({ secretHash: createHash('sha256').update(secret).digest('hex') })
.where(eq(schema.bots.id, bot.id))
return c.json({
botId: bot.id,
secret,
message: 'Secret regenerated. Your old secret no longer works. Save this immediately.',
})
})