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 }
|
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> {
|
async function registerHuman(name: string, avatarSeed?: string): Promise<BotData> {
|
||||||
if (!pubkey.value) throw new Error('Not logged in')
|
if (!pubkey.value) throw new Error('Not logged in')
|
||||||
|
|
||||||
@@ -547,6 +563,7 @@ export function useNostr() {
|
|||||||
registerHuman,
|
registerHuman,
|
||||||
updateCustomization,
|
updateCustomization,
|
||||||
updateWebhook,
|
updateWebhook,
|
||||||
|
regenerateSecret,
|
||||||
getStoredNsec,
|
getStoredNsec,
|
||||||
persistKey,
|
persistKey,
|
||||||
logout,
|
logout,
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { ensureAudioContext } from '../game/audio'
|
|||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
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
|
const botName = route.params.name as string
|
||||||
|
|
||||||
interface BotCustomization {
|
interface BotCustomization {
|
||||||
@@ -106,6 +106,13 @@ const webhookTestResult = ref<{ reachable: boolean; validResponse: boolean; late
|
|||||||
const webhookError = ref('')
|
const webhookError = ref('')
|
||||||
const webhookSuccess = 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 = [
|
const ARCHETYPES = [
|
||||||
'standard', 'lobster', 'sheep', 'cyborg', 'blob', 'tank', 'dog', 'cat',
|
'standard', 'lobster', 'sheep', 'cyborg', 'blob', 'tank', 'dog', 'cat',
|
||||||
'cactus', 'pizza', 'mushroom', 'shark', 'penguin', 'octopus', 'skeleton',
|
'cactus', 'pizza', 'mushroom', 'shark', 'penguin', 'octopus', 'skeleton',
|
||||||
@@ -268,6 +275,43 @@ async function saveWebhook() {
|
|||||||
isUpdatingWebhook.value = false
|
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 () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
const pk = pubkey.value || ''
|
const pk = pubkey.value || ''
|
||||||
@@ -779,6 +823,76 @@ const tierClass = (t: number) => `tier-${t}`
|
|||||||
</div>
|
</div>
|
||||||
</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) -->
|
<!-- Sign out (only if owner) -->
|
||||||
<div v-if="isOwner" class="mt-3 text-center flex-shrink-0">
|
<div v-if="isOwner" class="mt-3 text-center flex-shrink-0">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -535,8 +535,24 @@ function setupDocName() {
|
|||||||
return connectionMode.value === 'polling' ? 'BOTFIGHTS-POLLING.md' : 'BOTFIGHTS-WEBHOOK.md'
|
return connectionMode.value === 'polling' ? 'BOTFIGHTS-POLLING.md' : 'BOTFIGHTS-WEBHOOK.md'
|
||||||
}
|
}
|
||||||
|
|
||||||
function downloadSetupGuide() {
|
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')
|
window.open(setupDocPath(), '_blank')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function copyFullPrompt() {
|
function copyFullPrompt() {
|
||||||
|
|||||||
@@ -486,3 +486,41 @@ authRouter.post('/nostr/session', rateLimit(60_000, 10), async (c) => {
|
|||||||
bot: botData,
|
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