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:
co-authored by
Claude Opus 4.6
parent
47bc753f95
commit
cef9f4188f
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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.',
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user