Compare commits
4
Commits
d00e792bd9
...
10d4209675
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
10d4209675 | ||
|
|
41f1b93e9e | ||
|
|
c162d5ebe9 | ||
|
|
f5f57e60d9 |
@@ -18,7 +18,7 @@
|
|||||||
|
|
||||||
services:
|
services:
|
||||||
botfights-arena:
|
botfights-arena:
|
||||||
image: localhost:3000/lfg2025/botfights:1.2.9
|
image: localhost:3000/lfg2025/botfights:1.2.11
|
||||||
container_name: botfights-arena
|
container_name: botfights-arena
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@@ -380,7 +380,7 @@ export function useNostr() {
|
|||||||
const res = await authFetch('/api/auth/update', {
|
const res = await authFetch('/api/auth/update', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ pubkey: pubkey.value, customization }),
|
body: JSON.stringify({ customization }),
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -404,7 +404,7 @@ export function useNostr() {
|
|||||||
const res = await authFetch('/api/auth/update', {
|
const res = await authFetch('/api/auth/update', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ pubkey: pubkey.value, webhookUrl: newUrl }),
|
body: JSON.stringify({ webhookUrl: newUrl }),
|
||||||
})
|
})
|
||||||
|
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
|
|||||||
@@ -64,7 +64,6 @@ export function useWallet() {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
pubkey: pubkey.value,
|
|
||||||
method: 'nwc',
|
method: 'nwc',
|
||||||
connectionData: connectionString,
|
connectionData: connectionString,
|
||||||
}),
|
}),
|
||||||
@@ -93,7 +92,6 @@ export function useWallet() {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
pubkey: pubkey.value,
|
|
||||||
method: 'lnaddress',
|
method: 'lnaddress',
|
||||||
connectionData: address,
|
connectionData: address,
|
||||||
}),
|
}),
|
||||||
@@ -114,8 +112,6 @@ export function useWallet() {
|
|||||||
|
|
||||||
await authFetch('/api/payments/disconnect-wallet', {
|
await authFetch('/api/payments/disconnect-wallet', {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ pubkey: pubkey.value }),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
walletMethod.value = null
|
walletMethod.value = null
|
||||||
@@ -129,7 +125,7 @@ export function useWallet() {
|
|||||||
async function checkWalletStatus(): Promise<void> {
|
async function checkWalletStatus(): Promise<void> {
|
||||||
if (!pubkey.value) return
|
if (!pubkey.value) return
|
||||||
|
|
||||||
const res = await authFetch(`/api/payments/wallet-status?pubkey=${pubkey.value}`)
|
const res = await authFetch('/api/payments/wallet-status')
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
isWalletConnected.value = data.connected
|
isWalletConnected.value = data.connected
|
||||||
@@ -148,7 +144,7 @@ export function useWallet() {
|
|||||||
const invoiceRes = await authFetch('/api/payments/create-invoice', {
|
const invoiceRes = await authFetch('/api/payments/create-invoice', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ botId, pubkey: pubkey.value }),
|
body: JSON.stringify({ botId }),
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!invoiceRes.ok) {
|
if (!invoiceRes.ok) {
|
||||||
@@ -179,7 +175,7 @@ export function useWallet() {
|
|||||||
await authFetch(`/api/payments/confirm/${paymentId}`, {
|
await authFetch(`/api/payments/confirm/${paymentId}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ preimage, pubkey: pubkey.value }),
|
body: JSON.stringify({ preimage }),
|
||||||
})
|
})
|
||||||
paymentStatus.value = 'confirmed'
|
paymentStatus.value = 'confirmed'
|
||||||
return paymentId
|
return paymentId
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
|
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
|
||||||
import { useRoute, useRouter, RouterLink } from 'vue-router'
|
import { useRoute, useRouter, RouterLink } from 'vue-router'
|
||||||
import { useNostr, type NostrProfile } from '../composables/useNostr'
|
import { useNostr, type NostrProfile } from '../composables/useNostr'
|
||||||
|
import { authFetch } from '../lib/nostr-auth'
|
||||||
import SpritePreview from '../components/SpritePreview.vue'
|
import SpritePreview from '../components/SpritePreview.vue'
|
||||||
import HumanPreview from '../components/HumanPreview.vue'
|
import HumanPreview from '../components/HumanPreview.vue'
|
||||||
import WalletConnect from '../components/WalletConnect.vue'
|
import WalletConnect from '../components/WalletConnect.vue'
|
||||||
@@ -97,6 +98,17 @@ const showCustomize = ref(false)
|
|||||||
const isSaving = ref(false)
|
const isSaving = ref(false)
|
||||||
const custError = ref('')
|
const custError = ref('')
|
||||||
|
|
||||||
|
// Claim winnings (Cashu payouts sitting unclaimed — see payments.ts payWinner:
|
||||||
|
// mints a bearer token server-side when the winner has no NWC/Lightning-address
|
||||||
|
// wallet linked, which is the common case for a Cashu-primary bot). Fetched
|
||||||
|
// proactively (not behind a toggle) since this is the owner's own money.
|
||||||
|
interface UnclaimedWinning { paymentId: string; amountSats: number }
|
||||||
|
const unclaimedWinnings = ref<UnclaimedWinning[]>([])
|
||||||
|
const claimingId = ref<string | null>(null)
|
||||||
|
const claimError = ref('')
|
||||||
|
const claimedTokens = ref<{ paymentId: string; amountSats: number; token: string }[]>([])
|
||||||
|
const tokenCopiedId = ref<string | null>(null)
|
||||||
|
|
||||||
// Webhook management
|
// Webhook management
|
||||||
const showWebhook = ref(false)
|
const showWebhook = ref(false)
|
||||||
const webhookInput = ref('')
|
const webhookInput = ref('')
|
||||||
@@ -106,6 +118,68 @@ const webhookTestResult = ref<{ reachable: boolean; validResponse: boolean; late
|
|||||||
const webhookError = ref('')
|
const webhookError = ref('')
|
||||||
const webhookSuccess = ref('')
|
const webhookSuccess = ref('')
|
||||||
|
|
||||||
|
// AI-answer settings (existing bot — see /api/bots/:name/ai-config). Same
|
||||||
|
// feature as JoinBoutPage's creation-time setup, but reachable afterward:
|
||||||
|
// that flow only ever had the bot's own secret in hand at the moment of
|
||||||
|
// creation, with nowhere to come back to later.
|
||||||
|
const showAiConfig = ref(false)
|
||||||
|
const aiConfigLoaded = ref(false)
|
||||||
|
const aiConfigured = ref(false)
|
||||||
|
const aiConfigProvider = ref<'anthropic' | 'openai' | null>(null)
|
||||||
|
const aiProviderInput = ref<'anthropic' | 'openai'>('anthropic')
|
||||||
|
const aiApiKeyInput = ref('')
|
||||||
|
const aiConfigSaving = ref(false)
|
||||||
|
const aiConfigError = ref('')
|
||||||
|
|
||||||
|
async function loadAiConfig() {
|
||||||
|
if (!stats.value || aiConfigLoaded.value) return
|
||||||
|
try {
|
||||||
|
const res = await authFetch(`/api/bots/${encodeURIComponent(botName)}/ai-config`)
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json() as { configured: boolean; provider: 'anthropic' | 'openai' | null }
|
||||||
|
aiConfigured.value = data.configured
|
||||||
|
aiConfigProvider.value = data.provider
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[BotProfile] ai-config load failed:', err)
|
||||||
|
}
|
||||||
|
aiConfigLoaded.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveAiConfig() {
|
||||||
|
if (!aiApiKeyInput.value.trim() || aiConfigSaving.value) return
|
||||||
|
aiConfigSaving.value = true
|
||||||
|
aiConfigError.value = ''
|
||||||
|
try {
|
||||||
|
const res = await authFetch(`/api/bots/${encodeURIComponent(botName)}/ai-config`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ provider: aiProviderInput.value, apiKey: aiApiKeyInput.value.trim() }),
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) {
|
||||||
|
aiConfigError.value = data.error || 'Failed to save API key.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
aiConfigured.value = true
|
||||||
|
aiConfigProvider.value = aiProviderInput.value
|
||||||
|
aiApiKeyInput.value = '' // never keep the raw key in page state longer than needed
|
||||||
|
} catch {
|
||||||
|
aiConfigError.value = 'Connection failed. Try again.'
|
||||||
|
} finally {
|
||||||
|
aiConfigSaving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeAiConfig() {
|
||||||
|
try {
|
||||||
|
await authFetch(`/api/bots/${encodeURIComponent(botName)}/ai-config`, { method: 'DELETE' })
|
||||||
|
} finally {
|
||||||
|
aiConfigured.value = false
|
||||||
|
aiConfigProvider.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Setup guide
|
// Setup guide
|
||||||
const showSetupGuide = ref(false)
|
const showSetupGuide = ref(false)
|
||||||
const isRegenerating = ref(false)
|
const isRegenerating = ref(false)
|
||||||
@@ -240,6 +314,45 @@ async function saveCustomization() {
|
|||||||
isSaving.value = false
|
isSaving.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fetchUnclaimedWinnings() {
|
||||||
|
if (!stats.value || !isOwner.value) return
|
||||||
|
try {
|
||||||
|
const res = await authFetch(`/api/payments/winnings/${stats.value.id}`)
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json() as { unclaimed: UnclaimedWinning[] }
|
||||||
|
unclaimedWinnings.value = data.unclaimed || []
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[BotProfile] winnings fetch failed:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function claimWinning(paymentId: string, amountSats: number) {
|
||||||
|
if (claimingId.value) return
|
||||||
|
claimingId.value = paymentId
|
||||||
|
claimError.value = ''
|
||||||
|
try {
|
||||||
|
const res = await authFetch(`/api/payments/claim/${paymentId}`, { method: 'POST' })
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) {
|
||||||
|
claimError.value = data.error || 'Claim failed.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
claimedTokens.value.unshift({ paymentId, amountSats, token: data.cashuToken })
|
||||||
|
unclaimedWinnings.value = unclaimedWinnings.value.filter(w => w.paymentId !== paymentId)
|
||||||
|
} catch {
|
||||||
|
claimError.value = 'Network error claiming winnings.'
|
||||||
|
} finally {
|
||||||
|
claimingId.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyClaimedToken(paymentId: string, token: string) {
|
||||||
|
navigator.clipboard.writeText(token)
|
||||||
|
tokenCopiedId.value = paymentId
|
||||||
|
setTimeout(() => { if (tokenCopiedId.value === paymentId) tokenCopiedId.value = null }, 2500)
|
||||||
|
}
|
||||||
|
|
||||||
async function testWebhook() {
|
async function testWebhook() {
|
||||||
if (!stats.value || isTestingWebhook.value) return
|
if (!stats.value || isTestingWebhook.value) return
|
||||||
isTestingWebhook.value = true
|
isTestingWebhook.value = true
|
||||||
@@ -343,6 +456,10 @@ onMounted(async () => {
|
|||||||
}).catch(err => console.warn('[BotProfile] webhook test failed:', err))
|
}).catch(err => console.warn('[BotProfile] webhook test failed:', err))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Unclaimed Cashu winnings (non-blocking, owner only — fetchUnclaimedWinnings
|
||||||
|
// itself checks isOwner, but stats must be loaded first)
|
||||||
|
fetchUnclaimedWinnings()
|
||||||
|
|
||||||
// Poll queue for "choose your fight"
|
// Poll queue for "choose your fight"
|
||||||
pollQueue()
|
pollQueue()
|
||||||
pollHandle = setInterval(pollQueue, 4000)
|
pollHandle = setInterval(pollQueue, 4000)
|
||||||
@@ -602,6 +719,56 @@ const tierClass = (t: number) => `tier-${t}`
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Unclaimed Cashu winnings (owner only) — shown proactively, not
|
||||||
|
behind a toggle: this is real money waiting on the winner.
|
||||||
|
Payouts land here (instead of an auto-deposit) whenever the
|
||||||
|
winner has no NWC/Lightning-address wallet linked, which is
|
||||||
|
the common case for a Cashu-primary bot. -->
|
||||||
|
<div v-if="isOwner && unclaimedWinnings.length > 0" class="mt-2 border-2 border-neon-yellow/50 bg-neon-yellow/10 p-3">
|
||||||
|
<p class="font-display font-bold text-xs tracking-wider text-neon-yellow mb-2">
|
||||||
|
🏆 YOU WON {{ unclaimedWinnings.reduce((s, w) => s + w.amountSats, 0) }} SATS — CLAIM YOUR CASHU
|
||||||
|
</p>
|
||||||
|
<div v-for="w in unclaimedWinnings" :key="w.paymentId" class="flex items-center justify-between gap-2 mb-1.5 last:mb-0">
|
||||||
|
<span class="font-mono text-[10px] text-text-secondary">{{ w.amountSats }} sats</span>
|
||||||
|
<button
|
||||||
|
class="px-3 py-1.5 bg-neon-yellow/20 border border-neon-yellow/50 text-neon-yellow
|
||||||
|
font-display font-bold text-[10px] tracking-wider
|
||||||
|
hover:bg-neon-yellow/30 transition-all
|
||||||
|
disabled:opacity-30 disabled:cursor-not-allowed"
|
||||||
|
:disabled="claimingId === w.paymentId"
|
||||||
|
@click="claimWinning(w.paymentId, w.amountSats)"
|
||||||
|
>
|
||||||
|
{{ claimingId === w.paymentId ? 'CLAIMING...' : 'CLAIM' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p v-if="claimError" class="font-mono text-[10px] text-ko mt-1.5">{{ claimError }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Just-claimed tokens: bearer instruments, revealed once. Copy
|
||||||
|
into any Cashu wallet (e.g. Minibits) to redeem — there's no
|
||||||
|
"auto-deposit" for Cashu the way NWC allows for Lightning,
|
||||||
|
since a bearer token has no destination address to push to. -->
|
||||||
|
<div v-if="claimedTokens.length > 0" class="mt-2 border border-neon-green/40 bg-neon-green/5 p-3 space-y-2">
|
||||||
|
<div v-for="c in claimedTokens" :key="c.paymentId">
|
||||||
|
<p class="font-display font-bold text-[10px] tracking-wider text-neon-green mb-1">
|
||||||
|
✓ CLAIMED {{ c.amountSats }} SATS — paste into your Cashu wallet
|
||||||
|
</p>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<p class="flex-1 font-mono text-[9px] text-text-secondary break-all bg-black/30 border border-border p-1.5">
|
||||||
|
{{ c.token }}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
class="px-2 py-1.5 border border-neon-green/40 text-neon-green
|
||||||
|
font-display font-bold text-[9px] tracking-wider
|
||||||
|
hover:bg-neon-green/10 transition-all flex-shrink-0"
|
||||||
|
@click="copyClaimedToken(c.paymentId, c.token)"
|
||||||
|
>
|
||||||
|
{{ tokenCopiedId === c.paymentId ? 'COPIED' : 'COPY' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Wallet connection (owner only) -->
|
<!-- Wallet connection (owner only) -->
|
||||||
<div v-if="isOwner" class="mt-3">
|
<div v-if="isOwner" class="mt-3">
|
||||||
<WalletConnect />
|
<WalletConnect />
|
||||||
@@ -834,6 +1001,78 @@ const tierClass = (t: number) => `tier-${t}`
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- AI-answer settings (owner only, bots only): "let BotFights
|
||||||
|
answer for me" via an operator-supplied Anthropic/OpenAI key.
|
||||||
|
Same feature JoinBoutPage offers at creation time, now also
|
||||||
|
reachable afterward — for changing/rotating the key, or
|
||||||
|
turning it on for a bot that skipped it at creation. -->
|
||||||
|
<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="showAiConfig = !showAiConfig; if (showAiConfig) loadAiConfig()"
|
||||||
|
>
|
||||||
|
{{ showAiConfig ? 'HIDE' : 'AI ANSWER' }} SETTINGS
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div v-if="showAiConfig" class="mt-3 border border-border bg-surface-raised/60 p-4 space-y-3">
|
||||||
|
<p class="font-mono text-[10px] text-text-muted">
|
||||||
|
Let BotFights answer poll-mode fights for you using your own
|
||||||
|
Anthropic or OpenAI API key — no script or webhook required.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div v-if="!aiConfigLoaded" class="font-mono text-[10px] text-text-muted">Loading...</div>
|
||||||
|
|
||||||
|
<div v-else-if="aiConfigured" class="flex items-center justify-between p-2 border border-neon-green/30 bg-neon-green/5">
|
||||||
|
<span class="font-mono text-[10px] text-neon-green">
|
||||||
|
✓ Configured ({{ aiConfigProvider }})
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
class="px-2 py-1 border border-ko/40 text-ko font-display font-bold text-[9px] tracking-wider hover:bg-ko/10 transition-all"
|
||||||
|
@click="removeAiConfig"
|
||||||
|
>
|
||||||
|
REMOVE
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="space-y-2">
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button
|
||||||
|
v-for="p in (['anthropic', 'openai'] as const)"
|
||||||
|
:key="p"
|
||||||
|
class="flex-1 py-1.5 border font-display font-bold text-[9px] tracking-wider transition-all"
|
||||||
|
:class="aiProviderInput === p
|
||||||
|
? 'border-neon-purple/50 bg-neon-purple/10 text-neon-purple'
|
||||||
|
: 'border-border text-text-muted hover:border-neon-purple/30'"
|
||||||
|
@click="aiProviderInput = p"
|
||||||
|
>
|
||||||
|
{{ p.toUpperCase() }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
v-model="aiApiKeyInput"
|
||||||
|
type="password"
|
||||||
|
placeholder="sk-..."
|
||||||
|
class="w-full px-2 py-1.5 bg-black/30 border border-border font-mono text-[10px] text-text-primary
|
||||||
|
placeholder-text-muted/50 focus:outline-none focus:border-neon-purple/50"
|
||||||
|
@keyup.enter="saveAiConfig"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
class="w-full py-1.5 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
|
||||||
|
disabled:opacity-30 disabled:cursor-not-allowed"
|
||||||
|
:disabled="!aiApiKeyInput.trim() || aiConfigSaving"
|
||||||
|
@click="saveAiConfig"
|
||||||
|
>
|
||||||
|
{{ aiConfigSaving ? 'SAVING...' : 'SAVE KEY' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="aiConfigError" class="font-mono text-[10px] text-ko">{{ aiConfigError }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Setup guide download (owner only, bots only) -->
|
<!-- Setup guide download (owner only, bots only) -->
|
||||||
<div v-if="isOwner && stats.archetype !== 'human' && !stats.isHuman" class="mt-4">
|
<div v-if="isOwner && stats.archetype !== 'human' && !stats.isHuman" class="mt-4">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -717,10 +717,12 @@ async function fightRanked() {
|
|||||||
// don't create a duplicate Lightning invoice via payEntryFee().
|
// don't create a duplicate Lightning invoice via payEntryFee().
|
||||||
const paymentId = cashuPaymentId.value ?? await payEntryFee(bot.value.id)
|
const paymentId = cashuPaymentId.value ?? await payEntryFee(bot.value.id)
|
||||||
cashuPaymentId.value = null
|
cashuPaymentId.value = null
|
||||||
|
// Ownership is verified server-side from the Bearer JWT that authFetch
|
||||||
|
// attaches automatically — no client-supplied pubkey needed (or trusted).
|
||||||
const res = await authFetch(`/api/queue/join-ranked/${bot.value.id}`, {
|
const res = await authFetch(`/api/queue/join-ranked/${bot.value.id}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ paymentId, pubkey: pubkey.value }),
|
body: JSON.stringify({ paymentId }),
|
||||||
})
|
})
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
|
|||||||
@@ -132,14 +132,22 @@ describe('registerHumanSchema', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('updateBotSchema', () => {
|
describe('updateBotSchema', () => {
|
||||||
const base = { pubkey: 'a'.repeat(64) }
|
const base = {}
|
||||||
it('accepts pubkey only (no updates)', () => { expect(updateBotSchema.safeParse(base).success).toBe(true) })
|
it('accepts empty body (no updates)', () => { expect(updateBotSchema.safeParse(base).success).toBe(true) })
|
||||||
it('accepts webhook update', () => {
|
it('accepts webhook update', () => {
|
||||||
expect(updateBotSchema.safeParse({ ...base, webhookUrl: 'https://new.com/hook' }).success).toBe(true)
|
expect(updateBotSchema.safeParse({ ...base, webhookUrl: 'https://new.com/hook' }).success).toBe(true)
|
||||||
})
|
})
|
||||||
it('rejects file:// webhook', () => {
|
it('rejects file:// webhook', () => {
|
||||||
expect(updateBotSchema.safeParse({ ...base, webhookUrl: 'file:///etc/passwd' }).success).toBe(false)
|
expect(updateBotSchema.safeParse({ ...base, webhookUrl: 'file:///etc/passwd' }).success).toBe(false)
|
||||||
})
|
})
|
||||||
|
// SECURITY REGRESSION: pubkey must never be a schema field here. POST
|
||||||
|
// /api/auth/update derives identity from the verified JWT
|
||||||
|
// (extractPubkeyFromAuth), not from client body — see server/src/routes/auth.ts.
|
||||||
|
// A pubkey field in this schema previously let an unauthenticated caller
|
||||||
|
// claim any bot as their own and hijack its webhook/customization.
|
||||||
|
it('does not declare a pubkey field (identity comes from the JWT, not the body)', () => {
|
||||||
|
expect('pubkey' in updateBotSchema.shape).toBe(false)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// --- Fight schemas ---
|
// --- Fight schemas ---
|
||||||
|
|||||||
@@ -42,8 +42,13 @@ export const registerHumanSchema = z.object({
|
|||||||
avatarSeed: z.string().min(1).max(50).optional(),
|
avatarSeed: z.string().min(1).max(50).optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// pubkey is intentionally NOT part of this schema: POST /api/auth/update
|
||||||
|
// derives the caller's identity from their verified JWT (extractPubkeyFromAuth),
|
||||||
|
// never from the request body — a client-supplied pubkey here would let any
|
||||||
|
// caller act as any other bot owner. Kept accepting-but-ignoring the field
|
||||||
|
// would be more confusing than just not declaring it; the frontend no longer
|
||||||
|
// sends it either.
|
||||||
export const updateBotSchema = z.object({
|
export const updateBotSchema = z.object({
|
||||||
pubkey: pubkeySchema,
|
|
||||||
webhookUrl: httpUrlSchema.max(2048).optional(),
|
webhookUrl: httpUrlSchema.max(2048).optional(),
|
||||||
profilePicUrl: httpUrlSchema.max(2048).optional(),
|
profilePicUrl: httpUrlSchema.max(2048).optional(),
|
||||||
customization: z.record(z.string(), z.unknown()).optional().nullable(),
|
customization: z.record(z.string(), z.unknown()).optional().nullable(),
|
||||||
@@ -83,14 +88,15 @@ export const withdrawSchema = z.object({
|
|||||||
// --- Payment schemas ---
|
// --- Payment schemas ---
|
||||||
|
|
||||||
export const connectWalletSchema = z.object({
|
export const connectWalletSchema = z.object({
|
||||||
pubkey: pubkeySchema,
|
|
||||||
method: z.enum(['nwc', 'lnaddress', 'cashu_mint']),
|
method: z.enum(['nwc', 'lnaddress', 'cashu_mint']),
|
||||||
connectionData: z.string().min(1),
|
connectionData: z.string().min(1),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// pubkey is intentionally NOT part of this schema — see connect-wallet /
|
||||||
|
// create-invoice / claim / disconnect-wallet in payments.ts, which all
|
||||||
|
// derive ownership from the verified JWT, never a client-supplied field.
|
||||||
export const createInvoiceSchema = z.object({
|
export const createInvoiceSchema = z.object({
|
||||||
botId: idSchema,
|
botId: idSchema,
|
||||||
pubkey: pubkeySchema.optional(),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
export const submitCashuSchema = z.object({
|
export const submitCashuSchema = z.object({
|
||||||
@@ -104,9 +110,7 @@ export const zapSchema = z.object({
|
|||||||
amountSats: satsSchema,
|
amountSats: satsSchema,
|
||||||
})
|
})
|
||||||
|
|
||||||
export const disconnectWalletSchema = z.object({
|
export const disconnectWalletSchema = z.object({})
|
||||||
pubkey: pubkeySchema,
|
|
||||||
})
|
|
||||||
|
|
||||||
// --- Tournament schemas ---
|
// --- Tournament schemas ---
|
||||||
|
|
||||||
@@ -129,9 +133,11 @@ export const startTournamentSchema = z.object({
|
|||||||
|
|
||||||
// --- Queue schemas ---
|
// --- Queue schemas ---
|
||||||
|
|
||||||
|
// pubkey is intentionally NOT part of this schema — ownership is verified
|
||||||
|
// server-side via verifyBotOwner (JWT-derived pubkey or bot-secret), never
|
||||||
|
// from a client-supplied field. See queue.ts.
|
||||||
export const joinRankedSchema = z.object({
|
export const joinRankedSchema = z.object({
|
||||||
paymentId: idSchema,
|
paymentId: idSchema,
|
||||||
pubkey: pubkeySchema.optional(),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// --- Docs schemas ---
|
// --- Docs schemas ---
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { createHash, timingSafeEqual } from 'crypto'
|
|||||||
import { db, schema } from '../db/index.js'
|
import { db, schema } from '../db/index.js'
|
||||||
import { eq } from 'drizzle-orm'
|
import { eq } from 'drizzle-orm'
|
||||||
import type { Context } from 'hono'
|
import type { Context } from 'hono'
|
||||||
|
import { extractPubkeyFromAuth } from './jwt.js'
|
||||||
|
|
||||||
export interface BotAuthContext {
|
export interface BotAuthContext {
|
||||||
botId: string
|
botId: string
|
||||||
@@ -68,3 +69,40 @@ export async function authenticateBot(c: Context): Promise<BotAuthContext | Resp
|
|||||||
webhookUrl: rows[0].webhookUrl,
|
webhookUrl: rows[0].webhookUrl,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify the caller owns `botId`, for routes that must accept BOTH audiences:
|
||||||
|
* nostr-signed-in owners (web UI's JWT session) and anonymous poll-mode bots
|
||||||
|
* (Authorization: Bot <id>:<secret>, which never have a publicKey — see
|
||||||
|
* BOTFIGHTS.md). This is the ONLY correct way to check the nostr side: it
|
||||||
|
* derives pubkey from a verified JWT (extractPubkeyFromAuth), never from a
|
||||||
|
* client-supplied `pubkey` field. A bare `body.pubkey === bot.publicKey`
|
||||||
|
* comparison is not an ownership check at all — pubkeys are public by
|
||||||
|
* design in nostr (shown on every bot's own profile page), so anyone who's
|
||||||
|
* viewed a bot's page could pass that same auth-check with zero secret
|
||||||
|
* material. (This exact bug, at POST /api/auth/update, was found and fixed
|
||||||
|
* in 09-06 — see auth.ts. Same class, same fix, applied everywhere ownership
|
||||||
|
* is checked by pubkey.)
|
||||||
|
*/
|
||||||
|
export async function verifyBotOwner(c: Context, botId: string): Promise<true | Response> {
|
||||||
|
const auth = c.req.header('Authorization')
|
||||||
|
if (auth?.startsWith('Bearer ')) {
|
||||||
|
const pubkey = extractPubkeyFromAuth(auth)
|
||||||
|
if (!pubkey) {
|
||||||
|
return c.json({ error: 'Invalid or expired session.' }, 401)
|
||||||
|
}
|
||||||
|
const rows = await db.select({ publicKey: schema.bots.publicKey })
|
||||||
|
.from(schema.bots).where(eq(schema.bots.id, botId)).limit(1)
|
||||||
|
if (rows.length === 0 || rows[0].publicKey !== pubkey) {
|
||||||
|
return c.json({ error: 'Unauthorized' }, 403)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const botOrRes = await authenticateBot(c)
|
||||||
|
if (botOrRes instanceof Response) return botOrRes
|
||||||
|
if (botOrRes.botId !== botId) {
|
||||||
|
return c.json({ error: 'Unauthorized' }, 403)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,11 +2,13 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|||||||
import { Hono } from 'hono'
|
import { Hono } from 'hono'
|
||||||
|
|
||||||
// Mock DB: authenticateBot looks up a bot by id via db.select(...).from(...).where(...).limit(...)
|
// Mock DB: authenticateBot looks up a bot by id via db.select(...).from(...).where(...).limit(...)
|
||||||
|
const TEST_PUBKEY = 'b'.repeat(64)
|
||||||
const mockBotRow = {
|
const mockBotRow = {
|
||||||
id: 'bot_test123',
|
id: 'bot_test123',
|
||||||
name: 'testbot',
|
name: 'testbot',
|
||||||
secretHash: 'aa'.repeat(32), // placeholder; overridden per-test via crypto mock below
|
secretHash: 'aa'.repeat(32), // placeholder; overridden per-test via crypto mock below
|
||||||
webhookUrl: 'http://poll.local/',
|
webhookUrl: 'http://poll.local/',
|
||||||
|
publicKey: TEST_PUBKEY,
|
||||||
}
|
}
|
||||||
|
|
||||||
vi.mock('../db/index.js', () => ({
|
vi.mock('../db/index.js', () => ({
|
||||||
@@ -52,11 +54,13 @@ const REAL_SECRET = 'test-bot-secret-1234567890'
|
|||||||
mockBotRow.secretHash = createHash('sha256').update(REAL_SECRET).digest('hex')
|
mockBotRow.secretHash = createHash('sha256').update(REAL_SECRET).digest('hex')
|
||||||
|
|
||||||
const { botsRouter } = await import('./bots.js')
|
const { botsRouter } = await import('./bots.js')
|
||||||
|
const { createJwt } = await import('../middleware/jwt.js')
|
||||||
|
|
||||||
const app = new Hono()
|
const app = new Hono()
|
||||||
app.route('/api/bots', botsRouter)
|
app.route('/api/bots', botsRouter)
|
||||||
|
|
||||||
const AUTH = { Authorization: `Bot ${mockBotRow.id}:${REAL_SECRET}` }
|
const AUTH = { Authorization: `Bot ${mockBotRow.id}:${REAL_SECRET}` }
|
||||||
|
const OWNER_JWT_AUTH = { Authorization: `Bearer ${createJwt(TEST_PUBKEY)}` }
|
||||||
|
|
||||||
beforeEach(() => { store.clear() })
|
beforeEach(() => { store.clear() })
|
||||||
|
|
||||||
@@ -131,3 +135,66 @@ describe('bots ai-config routes', () => {
|
|||||||
expect(res.status).toBe(401)
|
expect(res.status).toBe(401)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// --- Existing-bot owner settings page: /api/bots/:name/ai-config ---
|
||||||
|
// These exist because the routes above require the bot's own secret, which
|
||||||
|
// is only ever available at the exact moment of creation (JoinBoutPage) —
|
||||||
|
// there was previously no way to add/change/remove an AI key for a bot
|
||||||
|
// after that moment, even for its nostr-logged-in owner.
|
||||||
|
describe('bots :name/ai-config routes (existing-bot owner settings)', () => {
|
||||||
|
it('GET /api/bots/:name/ai-config with a valid owner JWT returns configured status', async () => {
|
||||||
|
const res = await app.request('/api/bots/testbot/ai-config', { headers: OWNER_JWT_AUTH })
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
expect(await res.json()).toEqual({ configured: false, provider: null })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('POST /api/bots/:name/ai-config with a valid owner JWT sets the config', async () => {
|
||||||
|
const res = await app.request('/api/bots/testbot/ai-config', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { ...OWNER_JWT_AUTH, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ provider: 'anthropic', apiKey: 'sk-ant-fake-key-value' }),
|
||||||
|
})
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
expect(await res.json()).toEqual({ configured: true, provider: 'anthropic' })
|
||||||
|
|
||||||
|
// Same underlying storage as the bot-secret path — visible either way.
|
||||||
|
const check = await app.request('/api/bots/ai-config', { headers: AUTH })
|
||||||
|
expect(await check.json()).toEqual({ configured: true, provider: 'anthropic' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('DELETE /api/bots/:name/ai-config with a valid owner JWT removes the config', async () => {
|
||||||
|
await app.request('/api/bots/testbot/ai-config', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { ...OWNER_JWT_AUTH, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ provider: 'openai', apiKey: 'sk-openai-fake-key-value' }),
|
||||||
|
})
|
||||||
|
const del = await app.request('/api/bots/testbot/ai-config', { method: 'DELETE', headers: OWNER_JWT_AUTH })
|
||||||
|
expect(del.status).toBe(200)
|
||||||
|
const check = await app.request('/api/bots/testbot/ai-config', { headers: OWNER_JWT_AUTH })
|
||||||
|
expect(await check.json()).toEqual({ configured: false, provider: null })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects a JWT for a DIFFERENT pubkey than the bot owner (403)', async () => {
|
||||||
|
const wrongOwnerJwt = { Authorization: `Bearer ${createJwt('c'.repeat(64))}` }
|
||||||
|
const res = await app.request('/api/bots/testbot/ai-config', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { ...wrongOwnerJwt, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ provider: 'anthropic', apiKey: 'sk-ant-fake-key-value' }),
|
||||||
|
})
|
||||||
|
expect(res.status).toBe(403)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects requests with no auth at all (401)', async () => {
|
||||||
|
const res = await app.request('/api/bots/testbot/ai-config')
|
||||||
|
expect(res.status).toBe(401)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('also accepts the bot\'s own secret (Authorization: Bot id:secret) via verifyBotOwner', async () => {
|
||||||
|
const res = await app.request('/api/bots/testbot/ai-config', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { ...AUTH, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ provider: 'openai', apiKey: 'sk-openai-fake-key-value' }),
|
||||||
|
})
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { Hono } from 'hono'
|
||||||
|
import { authRouter } from './auth.js'
|
||||||
|
import { generateSecretKey, getPublicKey } from 'nostr-tools'
|
||||||
|
import { createJwt } from '../middleware/jwt.js'
|
||||||
|
import { db, schema } from '../db/index.js'
|
||||||
|
import { eq } from 'drizzle-orm'
|
||||||
|
import { createHash, randomBytes } from 'crypto'
|
||||||
|
import { nanoid } from 'nanoid'
|
||||||
|
|
||||||
|
const app = new Hono()
|
||||||
|
app.route('/api/auth', authRouter)
|
||||||
|
|
||||||
|
async function seedBot(pubkey: string, overrides: Partial<typeof schema.bots.$inferInsert> = {}) {
|
||||||
|
const id = overrides.id || nanoid(12)
|
||||||
|
const secret = randomBytes(32).toString('hex')
|
||||||
|
await db.insert(schema.bots).values({
|
||||||
|
id,
|
||||||
|
name: overrides.name || `t${Date.now().toString(36).slice(-8)}`,
|
||||||
|
webhookUrl: overrides.webhookUrl || 'http://poll.local/',
|
||||||
|
avatarSeed: overrides.avatarSeed || 'seed',
|
||||||
|
archetype: overrides.archetype || 'standard',
|
||||||
|
secretHash: createHash('sha256').update(secret).digest('hex'),
|
||||||
|
publicKey: pubkey,
|
||||||
|
profilePicUrl: overrides.profilePicUrl ?? null,
|
||||||
|
customization: overrides.customization ?? null,
|
||||||
|
createdAt: overrides.createdAt || new Date().toISOString(),
|
||||||
|
})
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
// SECURITY REGRESSION SUITE for POST /api/auth/update.
|
||||||
|
//
|
||||||
|
// This route previously trusted a client-supplied `pubkey` field in the
|
||||||
|
// request body with no verification against the caller's actual identity —
|
||||||
|
// any unauthenticated caller could pass a victim's pubkey and hijack their
|
||||||
|
// bot's webhook/profilePicUrl/customization. Found live during 09-06
|
||||||
|
// (ai-config UI work) by contrast with GET /me and POST /regenerate-secret,
|
||||||
|
// which both correctly derive identity from the verified JWT via
|
||||||
|
// extractPubkeyFromAuth. Fixed to always derive pubkey from the JWT; the
|
||||||
|
// body no longer even has a pubkey field (see updateBotSchema).
|
||||||
|
describe('POST /api/auth/update — identity comes from the JWT, not the body', () => {
|
||||||
|
it('rejects an unauthenticated request (no Authorization header)', async () => {
|
||||||
|
const res = await app.request('/api/auth/update', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ customization: { archetype: 'tank' } }),
|
||||||
|
})
|
||||||
|
expect(res.status).toBe(401)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects a garbage/tampered Bearer token', async () => {
|
||||||
|
const res = await app.request('/api/auth/update', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer not-a-real-jwt' },
|
||||||
|
body: JSON.stringify({ customization: { archetype: 'tank' } }),
|
||||||
|
})
|
||||||
|
expect(res.status).toBe(401)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('cannot hijack another bot by passing its pubkey in the body', async () => {
|
||||||
|
const victimSk = generateSecretKey()
|
||||||
|
const victimPk = getPublicKey(victimSk)
|
||||||
|
const victimId = await seedBot(victimPk, { webhookUrl: 'http://victim.local/original' })
|
||||||
|
|
||||||
|
// Attacker has their own valid session (their own JWT) but a DIFFERENT
|
||||||
|
// bot — no bot row at all, in this case.
|
||||||
|
const attackerPk = getPublicKey(generateSecretKey())
|
||||||
|
const attackerToken = createJwt(attackerPk)
|
||||||
|
|
||||||
|
const res = await app.request('/api/auth/update', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${attackerToken}` },
|
||||||
|
// Old exploit shape: claim to be the victim via a body field.
|
||||||
|
body: JSON.stringify({ pubkey: victimPk, webhookUrl: 'http://poll.local/attacker-controlled' }),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Attacker has no bot of their own -> 404, NOT a successful update of
|
||||||
|
// the victim's bot.
|
||||||
|
expect(res.status).toBe(404)
|
||||||
|
|
||||||
|
const rows = await db.select({ webhookUrl: schema.bots.webhookUrl })
|
||||||
|
.from(schema.bots)
|
||||||
|
.where(eq(schema.bots.id, victimId))
|
||||||
|
.limit(1)
|
||||||
|
expect(rows[0].webhookUrl).toBe('http://victim.local/original')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('updates the caller\'s own bot, derived from their JWT, ignoring a body pubkey', async () => {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
const pk = getPublicKey(sk)
|
||||||
|
const id = await seedBot(pk, { webhookUrl: 'http://poll.local/' })
|
||||||
|
const token = createJwt(pk)
|
||||||
|
|
||||||
|
const someoneElsesPk = getPublicKey(generateSecretKey())
|
||||||
|
const res = await app.request('/api/auth/update', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||||
|
// Even if a stale client sends an unrelated pubkey, the server must
|
||||||
|
// still act on the JWT-derived identity, not this field.
|
||||||
|
body: JSON.stringify({ pubkey: someoneElsesPk, customization: { archetype: 'shark' } }),
|
||||||
|
})
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
|
||||||
|
const rows = await db.select({ customization: schema.bots.customization })
|
||||||
|
.from(schema.bots)
|
||||||
|
.where(eq(schema.bots.id, id))
|
||||||
|
.limit(1)
|
||||||
|
expect(JSON.parse(rows[0].customization || '{}').archetype).toBe('shark')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -324,11 +324,25 @@ authRouter.post('/register-human', rateLimit(600_000, 10), async (c) => {
|
|||||||
|
|
||||||
// Update bot webhook and/or customization (requires pubkey match)
|
// Update bot webhook and/or customization (requires pubkey match)
|
||||||
authRouter.post('/update', rateLimit(60_000, 10), async (c) => {
|
authRouter.post('/update', rateLimit(60_000, 10), async (c) => {
|
||||||
|
// SECURITY: pubkey MUST come from the verified JWT, never from the request
|
||||||
|
// body. This handler previously trusted a client-supplied `pubkey` field
|
||||||
|
// with no cross-check against the caller's actual authenticated identity —
|
||||||
|
// any unauthenticated caller could POST an arbitrary victim's pubkey plus
|
||||||
|
// a malicious webhookUrl/profilePicUrl/customization and silently hijack
|
||||||
|
// that bot (e.g. redirect its webhook to an attacker-controlled endpoint).
|
||||||
|
// Found live during the ai-config UI work (09-06) by contrast with
|
||||||
|
// /regenerate-secret and GET /me, which both correctly derive pubkey from
|
||||||
|
// extractPubkeyFromAuth and never trust a client-claimed identity.
|
||||||
|
const pubkey = extractPubkeyFromAuth(c.req.header('Authorization'))
|
||||||
|
if (!pubkey) {
|
||||||
|
return c.json({ error: 'Authentication required.' }, 401)
|
||||||
|
}
|
||||||
|
|
||||||
const parsed = updateBotSchema.safeParse(await c.req.json().catch(() => ({})))
|
const parsed = updateBotSchema.safeParse(await c.req.json().catch(() => ({})))
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return c.json({ error: 'Invalid pubkey.' }, 400)
|
return c.json({ error: 'Invalid request body.' }, 400)
|
||||||
}
|
}
|
||||||
const { pubkey, webhookUrl, profilePicUrl, customization: rawCustomization } = parsed.data
|
const { webhookUrl, profilePicUrl, customization: rawCustomization } = parsed.data
|
||||||
|
|
||||||
const rows = await db.select({ id: schema.bots.id, customization: schema.bots.customization })
|
const rows = await db.select({ id: schema.bots.id, customization: schema.bots.customization })
|
||||||
.from(schema.bots)
|
.from(schema.bots)
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { isAllowedWebhookUrl } from '../engine/orchestrator.js'
|
|||||||
import { testWebhook } from '../engine/webhook-test.js'
|
import { testWebhook } from '../engine/webhook-test.js'
|
||||||
import { rateLimit } from '../middleware/rate-limit.js'
|
import { rateLimit } from '../middleware/rate-limit.js'
|
||||||
import { botNameSchema, httpUrlSchema } from '../lib/validators.js'
|
import { botNameSchema, httpUrlSchema } from '../lib/validators.js'
|
||||||
import { authenticateBot } from '../middleware/bot-auth.js'
|
import { authenticateBot, verifyBotOwner } from '../middleware/bot-auth.js'
|
||||||
import { setAiBotConfig, getAiBotConfig, deleteAiBotConfig, type LlmProvider } from '../engine/ai-bot-config.js'
|
import { setAiBotConfig, getAiBotConfig, deleteAiBotConfig, type LlmProvider } from '../engine/ai-bot-config.js'
|
||||||
|
|
||||||
export const botsRouter = new Hono()
|
export const botsRouter = new Hono()
|
||||||
@@ -193,6 +193,80 @@ botsRouter.delete('/ai-config', async (c) => {
|
|||||||
return c.json({ configured: false })
|
return c.json({ configured: false })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// --- Same feature, for an EXISTING bot's owner settings page ---
|
||||||
|
// The routes above require the bot's own secret (Authorization: Bot
|
||||||
|
// <id>:<secret>), which only the JoinBoutPage bot-creation flow has in hand
|
||||||
|
// at the moment of creation — it's never persisted anywhere the browser can
|
||||||
|
// re-fetch it. Before this, there was no way for an existing bot's owner to
|
||||||
|
// add, change, or remove their AI key later; they'd have to still be on the
|
||||||
|
// exact creation tab. These are owner-scoped by :name + nostr JWT
|
||||||
|
// (verifyBotOwner also accepts the bot's own secret, so an AI agent that
|
||||||
|
// happens to hold both could use either path — no harm either way).
|
||||||
|
//
|
||||||
|
// MUST be registered before GET /:name below for the same reason as
|
||||||
|
// /ai-config above (Hono resolves same-segment-count routes in registration
|
||||||
|
// order) — but :name/ai-config is a DIFFERENT segment count than :name, so
|
||||||
|
// it can't actually collide with it; kept adjacent for readability, not
|
||||||
|
// because ordering is load-bearing here.
|
||||||
|
botsRouter.get('/:name/ai-config', async (c) => {
|
||||||
|
const name = c.req.param('name')
|
||||||
|
if (!name) return c.json({ error: 'Bot not found.' }, 404)
|
||||||
|
const rows = await db.select({ id: schema.bots.id })
|
||||||
|
.from(schema.bots)
|
||||||
|
.where(eq(sql`LOWER(${schema.bots.name})`, name.toLowerCase()))
|
||||||
|
.limit(1)
|
||||||
|
if (rows.length === 0) return c.json({ error: 'Bot not found.' }, 404)
|
||||||
|
|
||||||
|
const ownerCheck = await verifyBotOwner(c, rows[0].id)
|
||||||
|
if (ownerCheck !== true) return ownerCheck
|
||||||
|
|
||||||
|
const config = getAiBotConfig(rows[0].id)
|
||||||
|
return c.json({ configured: !!config, provider: config?.provider ?? null })
|
||||||
|
})
|
||||||
|
|
||||||
|
botsRouter.post('/:name/ai-config', rateLimit(60_000, 10), async (c) => {
|
||||||
|
const name = c.req.param('name')
|
||||||
|
if (!name) return c.json({ error: 'Bot not found.' }, 404)
|
||||||
|
const rows = await db.select({ id: schema.bots.id })
|
||||||
|
.from(schema.bots)
|
||||||
|
.where(eq(sql`LOWER(${schema.bots.name})`, name.toLowerCase()))
|
||||||
|
.limit(1)
|
||||||
|
if (rows.length === 0) return c.json({ error: 'Bot not found.' }, 404)
|
||||||
|
|
||||||
|
const ownerCheck = await verifyBotOwner(c, rows[0].id)
|
||||||
|
if (ownerCheck !== true) return ownerCheck
|
||||||
|
|
||||||
|
const body = await c.req.json().catch(() => ({})) as { provider?: string; apiKey?: string }
|
||||||
|
const provider = body.provider
|
||||||
|
const apiKey = body.apiKey?.trim()
|
||||||
|
|
||||||
|
if (!provider || !AI_PROVIDERS.includes(provider as LlmProvider)) {
|
||||||
|
return c.json({ error: `provider must be one of: ${AI_PROVIDERS.join(', ')}` }, 400)
|
||||||
|
}
|
||||||
|
if (!apiKey || apiKey.length < 8 || apiKey.length > 512) {
|
||||||
|
return c.json({ error: 'apiKey is required (8-512 chars).' }, 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
setAiBotConfig(rows[0].id, { provider: provider as LlmProvider, apiKey })
|
||||||
|
return c.json({ configured: true, provider })
|
||||||
|
})
|
||||||
|
|
||||||
|
botsRouter.delete('/:name/ai-config', async (c) => {
|
||||||
|
const name = c.req.param('name')
|
||||||
|
if (!name) return c.json({ error: 'Bot not found.' }, 404)
|
||||||
|
const rows = await db.select({ id: schema.bots.id })
|
||||||
|
.from(schema.bots)
|
||||||
|
.where(eq(sql`LOWER(${schema.bots.name})`, name.toLowerCase()))
|
||||||
|
.limit(1)
|
||||||
|
if (rows.length === 0) return c.json({ error: 'Bot not found.' }, 404)
|
||||||
|
|
||||||
|
const ownerCheck = await verifyBotOwner(c, rows[0].id)
|
||||||
|
if (ownerCheck !== true) return ownerCheck
|
||||||
|
|
||||||
|
deleteAiBotConfig(rows[0].id)
|
||||||
|
return c.json({ configured: false })
|
||||||
|
})
|
||||||
|
|
||||||
// Get single bot profile
|
// Get single bot profile
|
||||||
botsRouter.get('/:name', async (c) => {
|
botsRouter.get('/:name', async (c) => {
|
||||||
const name = c.req.param('name')
|
const name = c.req.param('name')
|
||||||
|
|||||||
@@ -56,6 +56,16 @@ vi.mock('../middleware/rate-limit.js', () => ({
|
|||||||
const { paymentsRouter } = await import('./payments.js')
|
const { paymentsRouter } = await import('./payments.js')
|
||||||
const { db, schema } = await import('../db/index.js')
|
const { db, schema } = await import('../db/index.js')
|
||||||
const { createEntryInvoice, checkPaymentStatus } = await import('../engine/payments.js')
|
const { createEntryInvoice, checkPaymentStatus } = await import('../engine/payments.js')
|
||||||
|
// jwt.js is intentionally NOT mocked — connect-wallet, disconnect-wallet,
|
||||||
|
// wallet-status, winnings, and claim all derive identity from a real,
|
||||||
|
// verified JWT (see 09-06 IDOR fix), so tests that exercise the
|
||||||
|
// authenticated path need a real token, not a stubbed one.
|
||||||
|
const { createJwt } = await import('../middleware/jwt.js')
|
||||||
|
|
||||||
|
const TEST_PUBKEY = 'a'.repeat(64)
|
||||||
|
function authHeader(pubkey = TEST_PUBKEY) {
|
||||||
|
return { Authorization: `Bearer ${createJwt(pubkey)}` }
|
||||||
|
}
|
||||||
|
|
||||||
function makeApp() {
|
function makeApp() {
|
||||||
const app = new Hono()
|
const app = new Hono()
|
||||||
@@ -68,12 +78,22 @@ describe('payments routes', () => {
|
|||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('connect-wallet returns 400 when missing fields', async () => {
|
it('connect-wallet returns 401 with no Authorization header', async () => {
|
||||||
const app = makeApp()
|
const app = makeApp()
|
||||||
const res = await app.request('/api/payments/connect-wallet', {
|
const res = await app.request('/api/payments/connect-wallet', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ pubkey: 'abc' }),
|
body: JSON.stringify({ method: 'nwc', connectionData: 'x' }),
|
||||||
|
})
|
||||||
|
expect(res.status).toBe(401)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('connect-wallet returns 400 when missing fields', async () => {
|
||||||
|
const app = makeApp()
|
||||||
|
const res = await app.request('/api/payments/connect-wallet', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', ...authHeader() },
|
||||||
|
body: JSON.stringify({}),
|
||||||
})
|
})
|
||||||
expect(res.status).toBe(400)
|
expect(res.status).toBe(400)
|
||||||
const json = await res.json() as { error: string }
|
const json = await res.json() as { error: string }
|
||||||
@@ -85,9 +105,8 @@ describe('payments routes', () => {
|
|||||||
// db.select will return empty array by default
|
// db.select will return empty array by default
|
||||||
const res = await app.request('/api/payments/connect-wallet', {
|
const res = await app.request('/api/payments/connect-wallet', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json', ...authHeader() },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
pubkey: 'a'.repeat(64),
|
|
||||||
method: 'nwc',
|
method: 'nwc',
|
||||||
connectionData: 'nostr+walletconnect://test',
|
connectionData: 'nostr+walletconnect://test',
|
||||||
}),
|
}),
|
||||||
@@ -160,14 +179,14 @@ describe('payments routes', () => {
|
|||||||
expect(json.error).toContain('Missing')
|
expect(json.error).toContain('Missing')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('disconnect-wallet returns 400 when missing pubkey', async () => {
|
it('disconnect-wallet returns 401 with no Authorization header', async () => {
|
||||||
const app = makeApp()
|
const app = makeApp()
|
||||||
const res = await app.request('/api/payments/disconnect-wallet', {
|
const res = await app.request('/api/payments/disconnect-wallet', {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({}),
|
body: JSON.stringify({}),
|
||||||
})
|
})
|
||||||
expect(res.status).toBe(400)
|
expect(res.status).toBe(401)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('disconnect-wallet wipes connection data and sets hasWallet=false', async () => {
|
it('disconnect-wallet wipes connection data and sets hasWallet=false', async () => {
|
||||||
@@ -184,8 +203,8 @@ describe('payments routes', () => {
|
|||||||
const app = makeApp()
|
const app = makeApp()
|
||||||
const res = await app.request('/api/payments/disconnect-wallet', {
|
const res = await app.request('/api/payments/disconnect-wallet', {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json', ...authHeader() },
|
||||||
body: JSON.stringify({ pubkey: 'a'.repeat(64) }),
|
body: JSON.stringify({}),
|
||||||
})
|
})
|
||||||
expect(res.status).toBe(200)
|
expect(res.status).toBe(200)
|
||||||
const json = await res.json() as { success: boolean }
|
const json = await res.json() as { success: boolean }
|
||||||
@@ -314,9 +333,8 @@ describe('payment security — attack vectors', () => {
|
|||||||
const app = makeApp()
|
const app = makeApp()
|
||||||
const res = await app.request('/api/payments/connect-wallet', {
|
const res = await app.request('/api/payments/connect-wallet', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json', ...authHeader() },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
pubkey: 'a'.repeat(64),
|
|
||||||
method: 'paypal',
|
method: 'paypal',
|
||||||
connectionData: 'malicious://data',
|
connectionData: 'malicious://data',
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -6,17 +6,32 @@ import { eq } from 'drizzle-orm'
|
|||||||
import { createEntryInvoice, checkPaymentStatus, redeemCashuToken } from '../engine/payments.js'
|
import { createEntryInvoice, checkPaymentStatus, redeemCashuToken } from '../engine/payments.js'
|
||||||
import { encrypt, decrypt } from '../engine/crypto.js'
|
import { encrypt, decrypt } from '../engine/crypto.js'
|
||||||
import { rateLimit } from '../middleware/rate-limit.js'
|
import { rateLimit } from '../middleware/rate-limit.js'
|
||||||
import { connectWalletSchema, createInvoiceSchema, submitCashuSchema, disconnectWalletSchema, zapSchema, formatZodError, sanitizeError } from '../lib/validators.js'
|
import { connectWalletSchema, createInvoiceSchema, submitCashuSchema, zapSchema, formatZodError, sanitizeError } from '../lib/validators.js'
|
||||||
|
import { extractPubkeyFromAuth } from '../middleware/jwt.js'
|
||||||
|
|
||||||
export const paymentsRouter = new Hono()
|
export const paymentsRouter = new Hono()
|
||||||
|
|
||||||
// POST /connect-wallet
|
// POST /connect-wallet
|
||||||
|
//
|
||||||
|
// SECURITY: pubkey MUST come from the verified JWT, never the request body.
|
||||||
|
// This handler previously trusted a client-supplied `pubkey` field with NO
|
||||||
|
// ownership check at all — any unauthenticated caller could attach an
|
||||||
|
// attacker-controlled NWC connection string or Lightning Address to ANY
|
||||||
|
// victim bot by pubkey (public by design in nostr), silently redirecting
|
||||||
|
// all of that bot's future fight-winnings payouts to the attacker's own
|
||||||
|
// wallet. Direct fund theft, not just profile hijacking. Found and fixed
|
||||||
|
// alongside the identical pattern at POST /api/auth/update (09-06).
|
||||||
paymentsRouter.post('/connect-wallet', async (c) => {
|
paymentsRouter.post('/connect-wallet', async (c) => {
|
||||||
|
const pubkey = extractPubkeyFromAuth(c.req.header('Authorization'))
|
||||||
|
if (!pubkey) {
|
||||||
|
return c.json({ error: 'Authentication required.' }, 401)
|
||||||
|
}
|
||||||
|
|
||||||
const parsed = connectWalletSchema.safeParse(await c.req.json().catch(() => ({})))
|
const parsed = connectWalletSchema.safeParse(await c.req.json().catch(() => ({})))
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return c.json({ error: formatZodError(parsed.error, {}, 'Missing pubkey, method, or connectionData') }, 400)
|
return c.json({ error: formatZodError(parsed.error, {}, 'Missing method or connectionData') }, 400)
|
||||||
}
|
}
|
||||||
const { pubkey, method, connectionData } = parsed.data
|
const { method, connectionData } = parsed.data
|
||||||
|
|
||||||
// Look up bot by publicKey
|
// Look up bot by publicKey
|
||||||
const botRows = await db.select({ id: schema.bots.id })
|
const botRows = await db.select({ id: schema.bots.id })
|
||||||
@@ -59,9 +74,11 @@ paymentsRouter.post('/connect-wallet', async (c) => {
|
|||||||
return c.json({ success: true })
|
return c.json({ success: true })
|
||||||
})
|
})
|
||||||
|
|
||||||
// GET /wallet-status
|
// GET /wallet-status — read-only, but still derives identity from the JWT
|
||||||
|
// rather than a query-string pubkey, so this can't be used to enumerate
|
||||||
|
// whether an arbitrary victim pubkey has a wallet connected.
|
||||||
paymentsRouter.get('/wallet-status', async (c) => {
|
paymentsRouter.get('/wallet-status', async (c) => {
|
||||||
const pubkey = c.req.query('pubkey')
|
const pubkey = extractPubkeyFromAuth(c.req.header('Authorization'))
|
||||||
if (!pubkey) return c.json({ connected: false, method: null })
|
if (!pubkey) return c.json({ connected: false, method: null })
|
||||||
|
|
||||||
const botRows = await db.select({ id: schema.bots.id })
|
const botRows = await db.select({ id: schema.bots.id })
|
||||||
@@ -85,12 +102,14 @@ paymentsRouter.get('/wallet-status', async (c) => {
|
|||||||
paymentsRouter.post('/create-invoice', rateLimit(60_000, 10), async (c) => {
|
paymentsRouter.post('/create-invoice', rateLimit(60_000, 10), async (c) => {
|
||||||
const parsed = createInvoiceSchema.safeParse(await c.req.json().catch(() => ({})))
|
const parsed = createInvoiceSchema.safeParse(await c.req.json().catch(() => ({})))
|
||||||
if (!parsed.success) return c.json({ error: formatZodError(parsed.error, { botId: 'Missing botId' }, 'Missing botId') }, 400)
|
if (!parsed.success) return c.json({ error: formatZodError(parsed.error, { botId: 'Missing botId' }, 'Missing botId') }, 400)
|
||||||
const { botId, pubkey } = parsed.data
|
const { botId } = parsed.data
|
||||||
|
|
||||||
// In production, verify bot ownership
|
// In production, verify bot ownership via the verified JWT — never a
|
||||||
|
// client-supplied pubkey field (same fix class as connect-wallet above).
|
||||||
if (process.env.NODE_ENV === 'production') {
|
if (process.env.NODE_ENV === 'production') {
|
||||||
if (!pubkey || typeof pubkey !== 'string' || pubkey.length !== 64) {
|
const pubkey = extractPubkeyFromAuth(c.req.header('Authorization'))
|
||||||
return c.json({ error: 'Missing pubkey' }, 400)
|
if (!pubkey) {
|
||||||
|
return c.json({ error: 'Authentication required.' }, 401)
|
||||||
}
|
}
|
||||||
const botRows = await db.select({ publicKey: schema.bots.publicKey })
|
const botRows = await db.select({ publicKey: schema.bots.publicKey })
|
||||||
.from(schema.bots).where(eq(schema.bots.id, botId)).limit(1)
|
.from(schema.bots).where(eq(schema.bots.id, botId)).limit(1)
|
||||||
@@ -130,7 +149,7 @@ paymentsRouter.post('/confirm/:paymentId', rateLimit(60_000, 20), async (c) => {
|
|||||||
return c.json({ error: 'Invalid paymentId' }, 400)
|
return c.json({ error: 'Invalid paymentId' }, 400)
|
||||||
}
|
}
|
||||||
|
|
||||||
const { preimage, pubkey } = await c.req.json<{ preimage?: string; pubkey?: string }>().catch(() => ({ preimage: undefined, pubkey: undefined }))
|
const { preimage } = await c.req.json<{ preimage?: string }>().catch(() => ({ preimage: undefined }))
|
||||||
|
|
||||||
const rows = await db.select().from(schema.payments)
|
const rows = await db.select().from(schema.payments)
|
||||||
.where(eq(schema.payments.id, paymentId)).limit(1)
|
.where(eq(schema.payments.id, paymentId)).limit(1)
|
||||||
@@ -143,15 +162,19 @@ paymentsRouter.post('/confirm/:paymentId', rateLimit(60_000, 20), async (c) => {
|
|||||||
// Must be an inbound entry payment
|
// Must be an inbound entry payment
|
||||||
if (payment.direction !== 'in') return c.json({ error: 'Cannot confirm outbound payments' }, 400)
|
if (payment.direction !== 'in') return c.json({ error: 'Cannot confirm outbound payments' }, 400)
|
||||||
|
|
||||||
// Verify caller owns this payment's bot
|
// Verify caller owns this payment's bot — pubkey comes from the verified
|
||||||
if (pubkey && typeof pubkey === 'string' && pubkey.length === 64) {
|
// JWT, never a client-supplied field (same fix class as connect-wallet
|
||||||
|
// above: a bare body.pubkey === bot.publicKey check is not an ownership
|
||||||
|
// proof, since pubkeys are public by design in nostr).
|
||||||
|
const pubkey = extractPubkeyFromAuth(c.req.header('Authorization'))
|
||||||
|
if (pubkey) {
|
||||||
const botRows = await db.select({ publicKey: schema.bots.publicKey })
|
const botRows = await db.select({ publicKey: schema.bots.publicKey })
|
||||||
.from(schema.bots).where(eq(schema.bots.id, payment.botId)).limit(1)
|
.from(schema.bots).where(eq(schema.bots.id, payment.botId)).limit(1)
|
||||||
if (botRows.length === 0 || botRows[0].publicKey !== pubkey) {
|
if (botRows.length === 0 || botRows[0].publicKey !== pubkey) {
|
||||||
return c.json({ error: 'Unauthorized' }, 403)
|
return c.json({ error: 'Unauthorized' }, 403)
|
||||||
}
|
}
|
||||||
} else if (process.env.NODE_ENV === 'production') {
|
} else if (process.env.NODE_ENV === 'production') {
|
||||||
return c.json({ error: 'Missing or invalid pubkey' }, 400)
|
return c.json({ error: 'Authentication required.' }, 401)
|
||||||
}
|
}
|
||||||
|
|
||||||
// In production, also verify payment via NWC lookup (belt and suspenders)
|
// In production, also verify payment via NWC lookup (belt and suspenders)
|
||||||
@@ -204,18 +227,42 @@ paymentsRouter.post('/submit-cashu', async (c) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// GET /winnings/:botId
|
// GET /winnings/:botId
|
||||||
|
//
|
||||||
|
// SECURITY (critical): this handler previously had NO auth check at all AND
|
||||||
|
// returned the raw, spendable Cashu bearer token in the list response.
|
||||||
|
// botId is public (appears in every fight/profile URL), so anyone could
|
||||||
|
// list ANY bot's unclaimed winnings and get the live token back —
|
||||||
|
// no ownership proof needed whatsoever. Whoever holds a Cashu token can
|
||||||
|
// redeem it, so this leaked real, spendable sats to any caller who beat the
|
||||||
|
// legitimate winner to the request. Fixed: require JWT-derived ownership of
|
||||||
|
// botId, and never include the token itself in the list — only reveal it
|
||||||
|
// via the explicit POST /claim/:paymentId below, which also clears it from
|
||||||
|
// storage (single-use reveal, correct claim semantics).
|
||||||
paymentsRouter.get('/winnings/:botId', async (c) => {
|
paymentsRouter.get('/winnings/:botId', async (c) => {
|
||||||
const botId = c.req.param('botId')
|
const botId = c.req.param('botId')
|
||||||
|
|
||||||
|
const pubkey = extractPubkeyFromAuth(c.req.header('Authorization'))
|
||||||
|
if (!pubkey) {
|
||||||
|
return c.json({ error: 'Authentication required.' }, 401)
|
||||||
|
}
|
||||||
|
const botRows = await db.select({ publicKey: schema.bots.publicKey })
|
||||||
|
.from(schema.bots).where(eq(schema.bots.id, botId)).limit(1)
|
||||||
|
if (botRows.length === 0 || botRows[0].publicKey !== pubkey) {
|
||||||
|
return c.json({ error: 'Unauthorized' }, 403)
|
||||||
|
}
|
||||||
|
|
||||||
const unclaimed = await db.select({
|
const unclaimed = await db.select({
|
||||||
paymentId: schema.payments.id,
|
paymentId: schema.payments.id,
|
||||||
cashuToken: schema.payments.cashuToken,
|
hasToken: schema.payments.cashuToken,
|
||||||
amountSats: schema.payments.amountSats,
|
amountSats: schema.payments.amountSats,
|
||||||
}).from(schema.payments)
|
}).from(schema.payments)
|
||||||
.where(eq(schema.payments.botId, botId))
|
.where(eq(schema.payments.botId, botId))
|
||||||
|
|
||||||
// Filter in JS since drizzle doesn't easily combine multiple conditions
|
// Filter in JS since drizzle doesn't easily combine multiple conditions.
|
||||||
const filtered = unclaimed.filter(p => p.cashuToken)
|
// Never include the raw token here — see comment above.
|
||||||
|
const filtered = unclaimed
|
||||||
|
.filter(p => p.hasToken)
|
||||||
|
.map(p => ({ paymentId: p.paymentId, amountSats: p.amountSats }))
|
||||||
|
|
||||||
return c.json({ unclaimed: filtered })
|
return c.json({ unclaimed: filtered })
|
||||||
})
|
})
|
||||||
@@ -223,7 +270,6 @@ paymentsRouter.get('/winnings/:botId', async (c) => {
|
|||||||
// POST /claim/:paymentId
|
// POST /claim/:paymentId
|
||||||
paymentsRouter.post('/claim/:paymentId', async (c) => {
|
paymentsRouter.post('/claim/:paymentId', async (c) => {
|
||||||
const paymentId = c.req.param('paymentId')
|
const paymentId = c.req.param('paymentId')
|
||||||
const { pubkey } = await c.req.json<{ pubkey?: string }>().catch(() => ({ pubkey: undefined }))
|
|
||||||
|
|
||||||
const rows = await db.select().from(schema.payments)
|
const rows = await db.select().from(schema.payments)
|
||||||
.where(eq(schema.payments.id, paymentId))
|
.where(eq(schema.payments.id, paymentId))
|
||||||
@@ -233,7 +279,11 @@ paymentsRouter.post('/claim/:paymentId', async (c) => {
|
|||||||
|
|
||||||
const payment = rows[0]
|
const payment = rows[0]
|
||||||
|
|
||||||
// Verify caller owns this payment's bot
|
// Verify caller owns this payment's bot — pubkey comes from the verified
|
||||||
|
// JWT, never a client-supplied field. See GET /winnings above for the
|
||||||
|
// severity rationale (this route hands back a live, spendable bearer
|
||||||
|
// token — the single most sensitive check in this file).
|
||||||
|
const pubkey = extractPubkeyFromAuth(c.req.header('Authorization'))
|
||||||
if (pubkey) {
|
if (pubkey) {
|
||||||
const botRows = await db.select({ publicKey: schema.bots.publicKey })
|
const botRows = await db.select({ publicKey: schema.bots.publicKey })
|
||||||
.from(schema.bots).where(eq(schema.bots.id, payment.botId)).limit(1)
|
.from(schema.bots).where(eq(schema.bots.id, payment.botId)).limit(1)
|
||||||
@@ -241,7 +291,7 @@ paymentsRouter.post('/claim/:paymentId', async (c) => {
|
|||||||
return c.json({ error: 'Unauthorized' }, 403)
|
return c.json({ error: 'Unauthorized' }, 403)
|
||||||
}
|
}
|
||||||
} else if (process.env.NODE_ENV === 'production') {
|
} else if (process.env.NODE_ENV === 'production') {
|
||||||
return c.json({ error: 'Missing pubkey' }, 400)
|
return c.json({ error: 'Authentication required.' }, 401)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!payment.cashuToken) return c.json({ error: 'No Cashu token to claim' }, 400)
|
if (!payment.cashuToken) return c.json({ error: 'No Cashu token to claim' }, 400)
|
||||||
@@ -255,9 +305,10 @@ paymentsRouter.post('/claim/:paymentId', async (c) => {
|
|||||||
|
|
||||||
// DELETE /disconnect-wallet
|
// DELETE /disconnect-wallet
|
||||||
paymentsRouter.delete('/disconnect-wallet', async (c) => {
|
paymentsRouter.delete('/disconnect-wallet', async (c) => {
|
||||||
const parsed = disconnectWalletSchema.safeParse(await c.req.json().catch(() => ({})))
|
const pubkey = extractPubkeyFromAuth(c.req.header('Authorization'))
|
||||||
if (!parsed.success) return c.json({ error: formatZodError(parsed.error, {}, 'Missing pubkey') }, 400)
|
if (!pubkey) {
|
||||||
const { pubkey } = parsed.data
|
return c.json({ error: 'Authentication required.' }, 401)
|
||||||
|
}
|
||||||
|
|
||||||
const botRows = await db.select({ id: schema.bots.id })
|
const botRows = await db.select({ id: schema.bots.id })
|
||||||
.from(schema.bots)
|
.from(schema.bots)
|
||||||
|
|||||||
+12
-18
@@ -5,7 +5,7 @@ import { joinQueue, leaveQueue, getQueueSize, getQueueSnapshot } from '../engine
|
|||||||
import { joinRankedQueue, getRankedQueueStatus } from '../engine/ranked-queue.js'
|
import { joinRankedQueue, getRankedQueueStatus } from '../engine/ranked-queue.js'
|
||||||
import { rateLimit } from '../middleware/rate-limit.js'
|
import { rateLimit } from '../middleware/rate-limit.js'
|
||||||
import { joinRankedSchema, sanitizeError } from '../lib/validators.js'
|
import { joinRankedSchema, sanitizeError } from '../lib/validators.js'
|
||||||
import { authenticateBot } from '../middleware/bot-auth.js'
|
import { verifyBotOwner } from '../middleware/bot-auth.js'
|
||||||
|
|
||||||
export const queueRouter = new Hono()
|
export const queueRouter = new Hono()
|
||||||
|
|
||||||
@@ -57,7 +57,14 @@ queueRouter.get('/ranked-status', (c) => {
|
|||||||
// Join ranked queue — requires confirmed payment + bot ownership.
|
// Join ranked queue — requires confirmed payment + bot ownership.
|
||||||
// Ownership can be proven either way, since ranked/staked fights are for
|
// Ownership can be proven either way, since ranked/staked fights are for
|
||||||
// BOTH audiences (not just nostr-signed-in humans):
|
// BOTH audiences (not just nostr-signed-in humans):
|
||||||
// 1. pubkey (nostr-authenticated bots, the web UI's own JWT session flow)
|
// 1. Authorization: Bearer <jwt> (nostr-authenticated bots, the web UI's
|
||||||
|
// own JWT session flow) — verified via verifyBotOwner, which derives
|
||||||
|
// pubkey from the JWT itself, never from a client-supplied field. A
|
||||||
|
// bare `body.pubkey === bot.publicKey` comparison (the previous
|
||||||
|
// implementation here) is not an ownership check: pubkeys are public
|
||||||
|
// by design in nostr, shown on every bot's own profile page, so it let
|
||||||
|
// anyone who'd seen a bot's page join ranked queue as that bot. Found
|
||||||
|
// and fixed alongside the identical bug at POST /api/auth/update (09-06).
|
||||||
// 2. Authorization: Bot <id>:<secret> (anonymous poll-mode bots — the
|
// 2. Authorization: Bot <id>:<secret> (anonymous poll-mode bots — the
|
||||||
// primary registration path for AI agents per BOTFIGHTS.md, which
|
// primary registration path for AI agents per BOTFIGHTS.md, which
|
||||||
// never have a publicKey at all: confirmed live, publicKey is null
|
// never have a publicKey at all: confirmed live, publicKey is null
|
||||||
@@ -69,25 +76,12 @@ queueRouter.post('/join-ranked/:botId', async (c) => {
|
|||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return c.json({ error: parsed.error.issues[0]?.message || 'Missing paymentId' }, 400)
|
return c.json({ error: parsed.error.issues[0]?.message || 'Missing paymentId' }, 400)
|
||||||
}
|
}
|
||||||
const { paymentId, pubkey } = parsed.data
|
const { paymentId } = parsed.data
|
||||||
|
|
||||||
// Verify bot ownership in production
|
// Verify bot ownership in production
|
||||||
if (process.env.NODE_ENV === 'production') {
|
if (process.env.NODE_ENV === 'production') {
|
||||||
if (pubkey && typeof pubkey === 'string' && pubkey.length === 64) {
|
const ownerCheck = await verifyBotOwner(c, botId)
|
||||||
const botRows = await db.select({ publicKey: schema.bots.publicKey })
|
if (ownerCheck !== true) return ownerCheck
|
||||||
.from(schema.bots).where(eq(schema.bots.id, botId)).limit(1)
|
|
||||||
if (botRows.length === 0 || botRows[0].publicKey !== pubkey) {
|
|
||||||
return c.json({ error: 'Unauthorized' }, 403)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// No pubkey supplied — fall back to bot-secret auth (Authorization
|
|
||||||
// header or ?bot_id=&secret= query params, same as /api/fights/poll).
|
|
||||||
const botOrRes = await authenticateBot(c)
|
|
||||||
if (botOrRes instanceof Response) return botOrRes
|
|
||||||
if (botOrRes.botId !== botId) {
|
|
||||||
return c.json({ error: 'Unauthorized' }, 403)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user