Compare commits
4
Commits
d00e792bd9
...
10d4209675
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
10d4209675 | ||
|
|
41f1b93e9e | ||
|
|
c162d5ebe9 | ||
|
|
f5f57e60d9 |
@@ -18,7 +18,7 @@
|
||||
|
||||
services:
|
||||
botfights-arena:
|
||||
image: localhost:3000/lfg2025/botfights:1.2.9
|
||||
image: localhost:3000/lfg2025/botfights:1.2.11
|
||||
container_name: botfights-arena
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
|
||||
@@ -380,7 +380,7 @@ export function useNostr() {
|
||||
const res = await authFetch('/api/auth/update', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ pubkey: pubkey.value, customization }),
|
||||
body: JSON.stringify({ customization }),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
@@ -404,7 +404,7 @@ export function useNostr() {
|
||||
const res = await authFetch('/api/auth/update', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ pubkey: pubkey.value, webhookUrl: newUrl }),
|
||||
body: JSON.stringify({ webhookUrl: newUrl }),
|
||||
})
|
||||
|
||||
const data = await res.json()
|
||||
|
||||
@@ -64,7 +64,6 @@ export function useWallet() {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
pubkey: pubkey.value,
|
||||
method: 'nwc',
|
||||
connectionData: connectionString,
|
||||
}),
|
||||
@@ -93,7 +92,6 @@ export function useWallet() {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
pubkey: pubkey.value,
|
||||
method: 'lnaddress',
|
||||
connectionData: address,
|
||||
}),
|
||||
@@ -114,8 +112,6 @@ export function useWallet() {
|
||||
|
||||
await authFetch('/api/payments/disconnect-wallet', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ pubkey: pubkey.value }),
|
||||
})
|
||||
|
||||
walletMethod.value = null
|
||||
@@ -129,7 +125,7 @@ export function useWallet() {
|
||||
async function checkWalletStatus(): Promise<void> {
|
||||
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) {
|
||||
const data = await res.json()
|
||||
isWalletConnected.value = data.connected
|
||||
@@ -148,7 +144,7 @@ export function useWallet() {
|
||||
const invoiceRes = await authFetch('/api/payments/create-invoice', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ botId, pubkey: pubkey.value }),
|
||||
body: JSON.stringify({ botId }),
|
||||
})
|
||||
|
||||
if (!invoiceRes.ok) {
|
||||
@@ -179,7 +175,7 @@ export function useWallet() {
|
||||
await authFetch(`/api/payments/confirm/${paymentId}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ preimage, pubkey: pubkey.value }),
|
||||
body: JSON.stringify({ preimage }),
|
||||
})
|
||||
paymentStatus.value = 'confirmed'
|
||||
return paymentId
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useRoute, useRouter, RouterLink } from 'vue-router'
|
||||
import { useNostr, type NostrProfile } from '../composables/useNostr'
|
||||
import { authFetch } from '../lib/nostr-auth'
|
||||
import SpritePreview from '../components/SpritePreview.vue'
|
||||
import HumanPreview from '../components/HumanPreview.vue'
|
||||
import WalletConnect from '../components/WalletConnect.vue'
|
||||
@@ -97,6 +98,17 @@ const showCustomize = ref(false)
|
||||
const isSaving = ref(false)
|
||||
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
|
||||
const showWebhook = ref(false)
|
||||
const webhookInput = ref('')
|
||||
@@ -106,6 +118,68 @@ const webhookTestResult = ref<{ reachable: boolean; validResponse: boolean; late
|
||||
const webhookError = 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
|
||||
const showSetupGuide = ref(false)
|
||||
const isRegenerating = ref(false)
|
||||
@@ -240,6 +314,45 @@ async function saveCustomization() {
|
||||
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() {
|
||||
if (!stats.value || isTestingWebhook.value) return
|
||||
isTestingWebhook.value = true
|
||||
@@ -343,6 +456,10 @@ onMounted(async () => {
|
||||
}).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"
|
||||
pollQueue()
|
||||
pollHandle = setInterval(pollQueue, 4000)
|
||||
@@ -602,6 +719,56 @@ const tierClass = (t: number) => `tier-${t}`
|
||||
</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) -->
|
||||
<div v-if="isOwner" class="mt-3">
|
||||
<WalletConnect />
|
||||
@@ -834,6 +1001,78 @@ const tierClass = (t: number) => `tier-${t}`
|
||||
</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) -->
|
||||
<div v-if="isOwner && stats.archetype !== 'human' && !stats.isHuman" class="mt-4">
|
||||
<button
|
||||
|
||||
@@ -717,10 +717,12 @@ async function fightRanked() {
|
||||
// don't create a duplicate Lightning invoice via payEntryFee().
|
||||
const paymentId = cashuPaymentId.value ?? await payEntryFee(bot.value.id)
|
||||
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}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ paymentId, pubkey: pubkey.value }),
|
||||
body: JSON.stringify({ paymentId }),
|
||||
})
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
|
||||
@@ -132,14 +132,22 @@ describe('registerHumanSchema', () => {
|
||||
})
|
||||
|
||||
describe('updateBotSchema', () => {
|
||||
const base = { pubkey: 'a'.repeat(64) }
|
||||
it('accepts pubkey only (no updates)', () => { expect(updateBotSchema.safeParse(base).success).toBe(true) })
|
||||
const base = {}
|
||||
it('accepts empty body (no updates)', () => { expect(updateBotSchema.safeParse(base).success).toBe(true) })
|
||||
it('accepts webhook update', () => {
|
||||
expect(updateBotSchema.safeParse({ ...base, webhookUrl: 'https://new.com/hook' }).success).toBe(true)
|
||||
})
|
||||
it('rejects file:// webhook', () => {
|
||||
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 ---
|
||||
|
||||
@@ -42,8 +42,13 @@ export const registerHumanSchema = z.object({
|
||||
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({
|
||||
pubkey: pubkeySchema,
|
||||
webhookUrl: httpUrlSchema.max(2048).optional(),
|
||||
profilePicUrl: httpUrlSchema.max(2048).optional(),
|
||||
customization: z.record(z.string(), z.unknown()).optional().nullable(),
|
||||
@@ -83,14 +88,15 @@ export const withdrawSchema = z.object({
|
||||
// --- Payment schemas ---
|
||||
|
||||
export const connectWalletSchema = z.object({
|
||||
pubkey: pubkeySchema,
|
||||
method: z.enum(['nwc', 'lnaddress', 'cashu_mint']),
|
||||
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({
|
||||
botId: idSchema,
|
||||
pubkey: pubkeySchema.optional(),
|
||||
})
|
||||
|
||||
export const submitCashuSchema = z.object({
|
||||
@@ -104,9 +110,7 @@ export const zapSchema = z.object({
|
||||
amountSats: satsSchema,
|
||||
})
|
||||
|
||||
export const disconnectWalletSchema = z.object({
|
||||
pubkey: pubkeySchema,
|
||||
})
|
||||
export const disconnectWalletSchema = z.object({})
|
||||
|
||||
// --- Tournament schemas ---
|
||||
|
||||
@@ -129,9 +133,11 @@ export const startTournamentSchema = z.object({
|
||||
|
||||
// --- 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({
|
||||
paymentId: idSchema,
|
||||
pubkey: pubkeySchema.optional(),
|
||||
})
|
||||
|
||||
// --- Docs schemas ---
|
||||
|
||||
@@ -6,6 +6,7 @@ import { createHash, timingSafeEqual } from 'crypto'
|
||||
import { db, schema } from '../db/index.js'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import type { Context } from 'hono'
|
||||
import { extractPubkeyFromAuth } from './jwt.js'
|
||||
|
||||
export interface BotAuthContext {
|
||||
botId: string
|
||||
@@ -68,3 +69,40 @@ export async function authenticateBot(c: Context): Promise<BotAuthContext | Resp
|
||||
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'
|
||||
|
||||
// Mock DB: authenticateBot looks up a bot by id via db.select(...).from(...).where(...).limit(...)
|
||||
const TEST_PUBKEY = 'b'.repeat(64)
|
||||
const mockBotRow = {
|
||||
id: 'bot_test123',
|
||||
name: 'testbot',
|
||||
secretHash: 'aa'.repeat(32), // placeholder; overridden per-test via crypto mock below
|
||||
webhookUrl: 'http://poll.local/',
|
||||
publicKey: TEST_PUBKEY,
|
||||
}
|
||||
|
||||
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')
|
||||
|
||||
const { botsRouter } = await import('./bots.js')
|
||||
const { createJwt } = await import('../middleware/jwt.js')
|
||||
|
||||
const app = new Hono()
|
||||
app.route('/api/bots', botsRouter)
|
||||
|
||||
const AUTH = { Authorization: `Bot ${mockBotRow.id}:${REAL_SECRET}` }
|
||||
const OWNER_JWT_AUTH = { Authorization: `Bearer ${createJwt(TEST_PUBKEY)}` }
|
||||
|
||||
beforeEach(() => { store.clear() })
|
||||
|
||||
@@ -131,3 +135,66 @@ describe('bots ai-config routes', () => {
|
||||
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)
|
||||
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(() => ({})))
|
||||
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 })
|
||||
.from(schema.bots)
|
||||
|
||||
@@ -9,7 +9,7 @@ import { isAllowedWebhookUrl } from '../engine/orchestrator.js'
|
||||
import { testWebhook } from '../engine/webhook-test.js'
|
||||
import { rateLimit } from '../middleware/rate-limit.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'
|
||||
|
||||
export const botsRouter = new Hono()
|
||||
@@ -193,6 +193,80 @@ botsRouter.delete('/ai-config', async (c) => {
|
||||
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
|
||||
botsRouter.get('/:name', async (c) => {
|
||||
const name = c.req.param('name')
|
||||
|
||||
@@ -56,6 +56,16 @@ vi.mock('../middleware/rate-limit.js', () => ({
|
||||
const { paymentsRouter } = await import('./payments.js')
|
||||
const { db, schema } = await import('../db/index.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() {
|
||||
const app = new Hono()
|
||||
@@ -68,12 +78,22 @@ describe('payments routes', () => {
|
||||
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 res = await app.request('/api/payments/connect-wallet', {
|
||||
method: 'POST',
|
||||
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)
|
||||
const json = await res.json() as { error: string }
|
||||
@@ -85,9 +105,8 @@ describe('payments routes', () => {
|
||||
// db.select will return empty array by default
|
||||
const res = await app.request('/api/payments/connect-wallet', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: { 'Content-Type': 'application/json', ...authHeader() },
|
||||
body: JSON.stringify({
|
||||
pubkey: 'a'.repeat(64),
|
||||
method: 'nwc',
|
||||
connectionData: 'nostr+walletconnect://test',
|
||||
}),
|
||||
@@ -160,14 +179,14 @@ describe('payments routes', () => {
|
||||
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 res = await app.request('/api/payments/disconnect-wallet', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
})
|
||||
expect(res.status).toBe(400)
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
it('disconnect-wallet wipes connection data and sets hasWallet=false', async () => {
|
||||
@@ -184,8 +203,8 @@ describe('payments routes', () => {
|
||||
const app = makeApp()
|
||||
const res = await app.request('/api/payments/disconnect-wallet', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ pubkey: 'a'.repeat(64) }),
|
||||
headers: { 'Content-Type': 'application/json', ...authHeader() },
|
||||
body: JSON.stringify({}),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
const json = await res.json() as { success: boolean }
|
||||
@@ -314,9 +333,8 @@ describe('payment security — attack vectors', () => {
|
||||
const app = makeApp()
|
||||
const res = await app.request('/api/payments/connect-wallet', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: { 'Content-Type': 'application/json', ...authHeader() },
|
||||
body: JSON.stringify({
|
||||
pubkey: 'a'.repeat(64),
|
||||
method: 'paypal',
|
||||
connectionData: 'malicious://data',
|
||||
}),
|
||||
|
||||
@@ -6,17 +6,32 @@ import { eq } from 'drizzle-orm'
|
||||
import { createEntryInvoice, checkPaymentStatus, redeemCashuToken } from '../engine/payments.js'
|
||||
import { encrypt, decrypt } from '../engine/crypto.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()
|
||||
|
||||
// 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) => {
|
||||
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(() => ({})))
|
||||
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
|
||||
const botRows = await db.select({ id: schema.bots.id })
|
||||
@@ -59,9 +74,11 @@ paymentsRouter.post('/connect-wallet', async (c) => {
|
||||
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) => {
|
||||
const pubkey = c.req.query('pubkey')
|
||||
const pubkey = extractPubkeyFromAuth(c.req.header('Authorization'))
|
||||
if (!pubkey) return c.json({ connected: false, method: null })
|
||||
|
||||
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) => {
|
||||
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)
|
||||
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 (!pubkey || typeof pubkey !== 'string' || pubkey.length !== 64) {
|
||||
return c.json({ error: 'Missing pubkey' }, 400)
|
||||
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)
|
||||
@@ -130,7 +149,7 @@ paymentsRouter.post('/confirm/:paymentId', rateLimit(60_000, 20), async (c) => {
|
||||
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)
|
||||
.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
|
||||
if (payment.direction !== 'in') return c.json({ error: 'Cannot confirm outbound payments' }, 400)
|
||||
|
||||
// Verify caller owns this payment's bot
|
||||
if (pubkey && typeof pubkey === 'string' && pubkey.length === 64) {
|
||||
// Verify caller owns this payment's bot — pubkey comes from the verified
|
||||
// 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 })
|
||||
.from(schema.bots).where(eq(schema.bots.id, payment.botId)).limit(1)
|
||||
if (botRows.length === 0 || botRows[0].publicKey !== pubkey) {
|
||||
return c.json({ error: 'Unauthorized' }, 403)
|
||||
}
|
||||
} 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)
|
||||
@@ -204,18 +227,42 @@ paymentsRouter.post('/submit-cashu', async (c) => {
|
||||
})
|
||||
|
||||
// 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) => {
|
||||
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({
|
||||
paymentId: schema.payments.id,
|
||||
cashuToken: schema.payments.cashuToken,
|
||||
hasToken: schema.payments.cashuToken,
|
||||
amountSats: schema.payments.amountSats,
|
||||
}).from(schema.payments)
|
||||
.where(eq(schema.payments.botId, botId))
|
||||
|
||||
// Filter in JS since drizzle doesn't easily combine multiple conditions
|
||||
const filtered = unclaimed.filter(p => p.cashuToken)
|
||||
// Filter in JS since drizzle doesn't easily combine multiple conditions.
|
||||
// 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 })
|
||||
})
|
||||
@@ -223,7 +270,6 @@ paymentsRouter.get('/winnings/:botId', async (c) => {
|
||||
// POST /claim/:paymentId
|
||||
paymentsRouter.post('/claim/:paymentId', async (c) => {
|
||||
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)
|
||||
.where(eq(schema.payments.id, paymentId))
|
||||
@@ -233,7 +279,11 @@ paymentsRouter.post('/claim/:paymentId', async (c) => {
|
||||
|
||||
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) {
|
||||
const botRows = await db.select({ publicKey: schema.bots.publicKey })
|
||||
.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)
|
||||
}
|
||||
} 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)
|
||||
@@ -255,9 +305,10 @@ paymentsRouter.post('/claim/:paymentId', async (c) => {
|
||||
|
||||
// DELETE /disconnect-wallet
|
||||
paymentsRouter.delete('/disconnect-wallet', async (c) => {
|
||||
const parsed = disconnectWalletSchema.safeParse(await c.req.json().catch(() => ({})))
|
||||
if (!parsed.success) return c.json({ error: formatZodError(parsed.error, {}, 'Missing pubkey') }, 400)
|
||||
const { pubkey } = parsed.data
|
||||
const pubkey = extractPubkeyFromAuth(c.req.header('Authorization'))
|
||||
if (!pubkey) {
|
||||
return c.json({ error: 'Authentication required.' }, 401)
|
||||
}
|
||||
|
||||
const botRows = await db.select({ id: schema.bots.id })
|
||||
.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 { rateLimit } from '../middleware/rate-limit.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()
|
||||
|
||||
@@ -57,7 +57,14 @@ queueRouter.get('/ranked-status', (c) => {
|
||||
// Join ranked queue — requires confirmed payment + bot ownership.
|
||||
// Ownership can be proven either way, since ranked/staked fights are for
|
||||
// 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
|
||||
// primary registration path for AI agents per BOTFIGHTS.md, which
|
||||
// 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) {
|
||||
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
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
if (pubkey && typeof pubkey === 'string' && pubkey.length === 64) {
|
||||
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)
|
||||
}
|
||||
} 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)
|
||||
}
|
||||
}
|
||||
const ownerCheck = await verifyBotOwner(c, botId)
|
||||
if (ownerCheck !== true) return ownerCheck
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user