import { Hono } from 'hono' import { nanoid } from 'nanoid' import { createHash, randomBytes } from 'crypto' import { db, schema } from '../db/index.js' import { eq, sql } from 'drizzle-orm' import { isAllowedWebhookUrl } from '../engine/orchestrator.js' import { validateCustomization } from '../engine/customization.js' import { testWebhook } from '../engine/webhook-test.js' import { rateLimit } from '../middleware/rate-limit.js' import { isCreatorPubkey } from '../lib/constants.js' import { loginSchema, registerSchema, registerHumanSchema, updateBotSchema, pubkeySchema, formatZodError } from '../lib/validators.js' import { extractPubkeyFromAuth } from '../middleware/jwt.js' export const authRouter = new Hono() // Check name availability authRouter.get("/check-name/:name", async (c) => { const name = c.req.param("name")?.trim().toLowerCase() if (!name || name.length < 2 || name.length > 12) { return c.json({ available: false, error: "Name must be 2-12 characters." }) } const existing = await db.select({ id: schema.bots.id }) .from(schema.bots) .where(eq(sql`LOWER(${schema.bots.name})`, name)) .limit(1) return c.json({ available: existing.length === 0 }) }) // GET /me — restore the caller's own identity from their JWT alone. // This is the ONLY session-restore path: it derives the pubkey from a // verified, non-expired, non-blacklisted Bearer token (extractPubkeyFromAuth // delegates to verifyJwt, which covers all of those cases) and never trusts // a client-claimed pubkey. Read-only — performs no writes of any kind. // Covered by global /api/* rate limiting (see app.ts); no per-route limiter // needed for a session-restore call issued on every page load. authRouter.get('/me', async (c) => { const pubkey = extractPubkeyFromAuth(c.req.header('Authorization')) if (!pubkey) { return c.json({ error: 'Authentication required.' }, 401) } const rows = await db.select({ id: schema.bots.id, name: schema.bots.name, avatarSeed: schema.bots.avatarSeed, archetype: schema.bots.archetype, profilePicUrl: schema.bots.profilePicUrl, eloRating: schema.bots.eloRating, wins: schema.bots.wins, losses: schema.bots.losses, winStreak: schema.bots.winStreak, bestStreak: schema.bots.bestStreak, tier: schema.bots.tier, isActive: schema.bots.isActive, customization: schema.bots.customization, webhookUrl: schema.bots.webhookUrl, satsWon: schema.bots.satsWon, satsWagered: schema.bots.satsWagered, }).from(schema.bots).where(eq(schema.bots.publicKey, pubkey)).limit(1) if (rows.length === 0) { return c.json({ exists: false }) } const bot = rows[0] const isHuman = bot.webhookUrl === 'http://human.local/' return c.json({ exists: true, bot: { id: bot.id, name: bot.name, avatarSeed: bot.avatarSeed, archetype: bot.archetype, profilePicUrl: bot.profilePicUrl, eloRating: bot.eloRating, wins: bot.wins, losses: bot.losses, winStreak: bot.winStreak, bestStreak: bot.bestStreak, tier: bot.tier, isActive: bot.isActive, isHuman, customization: bot.customization ? JSON.parse(bot.customization) : null, satsWon: bot.satsWon ?? 0, satsWagered: bot.satsWagered ?? 0, hasWallet: false, }, }) }) // DEPRECATED — read-only lookup kept for backward compatibility only. // This endpoint establishes NO session and issues NO token; it never trusts // the pubkey it's given beyond looking up an existing row (D-01/BOT-01). // It used to auto-create/auto-upgrade the creator's bot row on an // unauthenticated request — that side effect has been removed. The // identical creator auto-create/auto-upgrade logic runs, correctly gated // behind NIP-98 signature verification, inside POST /nostr/session; a // creator who signs in with a real signer still gets the same row // created/upgraded there. Session establishment lives ONLY in // POST /nostr/session; session restoration lives ONLY in GET /me. // Rate limited: 10 per minute per IP. authRouter.post('/login', rateLimit(60_000, 10), async (c) => { const parsed = loginSchema.safeParse(await c.req.json().catch(() => ({}))) if (!parsed.success) { return c.json({ error: 'Invalid pubkey.' }, 400) } const { pubkey } = parsed.data const rows = await db.select({ id: schema.bots.id, name: schema.bots.name, avatarSeed: schema.bots.avatarSeed, archetype: schema.bots.archetype, profilePicUrl: schema.bots.profilePicUrl, eloRating: schema.bots.eloRating, wins: schema.bots.wins, losses: schema.bots.losses, winStreak: schema.bots.winStreak, bestStreak: schema.bots.bestStreak, tier: schema.bots.tier, isActive: schema.bots.isActive, customization: schema.bots.customization, webhookUrl: schema.bots.webhookUrl, satsWon: schema.bots.satsWon, satsWagered: schema.bots.satsWagered, }).from(schema.bots).where(eq(schema.bots.publicKey, pubkey)).limit(1) if (rows.length === 0) { return c.json({ exists: false, pubkey }) } const bot = rows[0] const isHuman = bot.webhookUrl === 'http://human.local/' return c.json({ exists: true, bot: { id: bot.id, name: bot.name, avatarSeed: bot.avatarSeed, archetype: bot.archetype, profilePicUrl: bot.profilePicUrl, eloRating: bot.eloRating, wins: bot.wins, losses: bot.losses, winStreak: bot.winStreak, bestStreak: bot.bestStreak, tier: bot.tier, isActive: bot.isActive, isHuman, customization: bot.customization ? JSON.parse(bot.customization) : null, satsWon: bot.satsWon ?? 0, satsWagered: bot.satsWagered ?? 0, hasWallet: false, }, }) }) // Register a new bot with Nostr pubkey authRouter.post('/register', rateLimit(600_000, 10), async (c) => { const parsed = registerSchema.safeParse(await c.req.json().catch(() => ({}))) if (!parsed.success) { return c.json({ error: formatZodError(parsed.error, { pubkey: 'Invalid pubkey.', name: 'Name must be 2-12 alphanumeric characters, hyphens, or underscores.', webhookUrl: 'webhookUrl must be a valid URL.', }, 'Invalid registration data.') }, 400) } const { pubkey, name, webhookUrl, archetype, profilePicUrl, customization: rawCustomization } = parsed.data // Validate customization const custResult = validateCustomization(rawCustomization) if (!custResult.valid) { return c.json({ error: custResult.error }, 400) } const normalizedName = name.toLowerCase() // Poll mode: if no webhookUrl provided, bot will poll for challenges const isPollMode = !webhookUrl || webhookUrl === '' if (!isPollMode) { if (typeof webhookUrl !== 'string') { return c.json({ error: 'webhookUrl must be a string.' }, 400) } try { new URL(webhookUrl) } catch { return c.json({ error: 'webhookUrl must be a valid URL.' }, 400) } if (!isAllowedWebhookUrl(webhookUrl)) { return c.json({ error: 'webhookUrl must not point to private/internal addresses.' }, 400) } } // Check pubkey not already used const existingPk = await db.select({ id: schema.bots.id }) .from(schema.bots) .where(eq(schema.bots.publicKey, pubkey)) .limit(1) if (existingPk.length > 0) { return c.json({ error: 'This Nostr key already has a bot.' }, 409) } // Check name not taken (case-insensitive) const existingName = await db.select({ id: schema.bots.id }) .from(schema.bots) .where(eq(sql`LOWER(${schema.bots.name})`, normalizedName)) .limit(1) if (existingName.length > 0) { return c.json({ error: 'A bot with that name already exists.' }, 409) } // Test the webhook (skip for poll mode) let testResult: { latencyMs: number } | null = null if (!isPollMode) { const result = await testWebhook(webhookUrl) if (!result.reachable || !result.validResponse) { return c.json({ error: 'Webhook verification failed.', details: result.error || 'Webhook must return {"answer": "..."} as JSON.', latencyMs: result.latencyMs, }, 422) } testResult = result } const id = nanoid(12) const secret = randomBytes(32).toString('hex') const effectiveWebhookUrl = isPollMode ? 'http://poll.local/' : webhookUrl const baseArchetype = custResult.data.archetype || archetype || 'standard' const effectiveArchetype = isCreatorPubkey(pubkey) ? 'the_creator' : baseArchetype const custJson = Object.keys(custResult.data).length > 0 ? JSON.stringify(custResult.data) : null await db.insert(schema.bots).values({ id, name: normalizedName, webhookUrl: effectiveWebhookUrl, avatarSeed: normalizedName, archetype: effectiveArchetype, secretHash: createHash('sha256').update(secret).digest('hex'), publicKey: pubkey, profilePicUrl: profilePicUrl || null, customization: custJson, createdAt: new Date().toISOString(), }) return c.json({ id, name: normalizedName, secret, mode: isPollMode ? 'poll' : 'webhook', archetype: effectiveArchetype, customization: custResult.data, webhookLatencyMs: testResult?.latencyMs ?? null, message: isPollMode ? 'Bot registered in poll mode. No public URL needed. Use GET /api/fights/poll to receive challenges.' : 'Bot registered. Webhook verified.', }, 201) }) // Register a human player (no webhook required) authRouter.post('/register-human', rateLimit(600_000, 10), async (c) => { const parsed = registerHumanSchema.safeParse(await c.req.json().catch(() => ({}))) if (!parsed.success) { return c.json({ error: formatZodError(parsed.error, { pubkey: 'Invalid pubkey.', name: 'Name must be 2-12 alphanumeric characters, hyphens, or underscores.', }, 'Invalid registration data.') }, 400) } const { pubkey, name, profilePicUrl, avatarSeed } = parsed.data const normalizedName = name.toLowerCase() const existingPk = await db.select({ id: schema.bots.id }) .from(schema.bots) .where(eq(schema.bots.publicKey, pubkey)) .limit(1) if (existingPk.length > 0) { return c.json({ error: 'This Nostr key already has a fighter.' }, 409) } const existingName = await db.select({ id: schema.bots.id }) .from(schema.bots) .where(eq(sql`LOWER(${schema.bots.name})`, normalizedName)) .limit(1) if (existingName.length > 0) { return c.json({ error: 'That name is already taken.' }, 409) } const id = nanoid(12) const secret = randomBytes(32).toString('hex') const humanArchetype = isCreatorPubkey(pubkey) ? 'the_creator' : 'human' await db.insert(schema.bots).values({ id, name: normalizedName, webhookUrl: 'http://human.local/', avatarSeed: avatarSeed || normalizedName, archetype: humanArchetype, secretHash: createHash('sha256').update(secret).digest('hex'), publicKey: pubkey, profilePicUrl: profilePicUrl || null, customization: null, createdAt: new Date().toISOString(), }) return c.json({ id, name: normalizedName, archetype: humanArchetype, isHuman: true, message: 'Human fighter registered.', }, 201) }) // Update bot webhook and/or customization (requires pubkey match) authRouter.post('/update', rateLimit(60_000, 10), async (c) => { const parsed = updateBotSchema.safeParse(await c.req.json().catch(() => ({}))) if (!parsed.success) { return c.json({ error: 'Invalid pubkey.' }, 400) } const { pubkey, webhookUrl, profilePicUrl, customization: rawCustomization } = parsed.data const rows = await db.select({ id: schema.bots.id, customization: schema.bots.customization }) .from(schema.bots) .where(eq(schema.bots.publicKey, pubkey)) .limit(1) if (rows.length === 0) { return c.json({ error: 'No bot found for this key.' }, 404) } const updates: Record = {} if (webhookUrl) { if (typeof webhookUrl !== 'string' || webhookUrl.length > 2048) { return c.json({ error: 'Invalid webhookUrl.' }, 400) } if (!isAllowedWebhookUrl(webhookUrl)) { return c.json({ error: 'webhookUrl must not point to private/internal addresses.' }, 400) } const testResult = await testWebhook(webhookUrl) if (!testResult.reachable || !testResult.validResponse) { return c.json({ error: 'Webhook verification failed.', details: testResult.error, }, 422) } updates.webhookUrl = webhookUrl updates.consecutiveErrors = 0 updates.isActive = true } if (profilePicUrl) { if (typeof profilePicUrl !== 'string' || profilePicUrl.length > 2048 || !/^https?:\/\//.test(profilePicUrl)) { return c.json({ error: 'profilePicUrl must be a valid HTTP(S) URL.' }, 400) } updates.profilePicUrl = profilePicUrl } if (rawCustomization !== undefined) { const custResult = validateCustomization(rawCustomization) if (!custResult.valid) { return c.json({ error: custResult.error }, 400) } // Merge with existing customization const existing = rows[0].customization ? JSON.parse(rows[0].customization) : {} const merged = { ...existing, ...custResult.data } updates.customization = JSON.stringify(merged) // Update archetype if set in customization (but never override the_creator) if (custResult.data.archetype && !isCreatorPubkey(pubkey)) { updates.archetype = custResult.data.archetype } } if (Object.keys(updates).length > 0) { await db.update(schema.bots).set(updates).where(eq(schema.bots.id, rows[0].id)) } return c.json({ updated: true }) }) // --- NIP-98 Authenticated Session --- import { verifyNip98Token } from '../middleware/nip98.js' import { createJwt } from '../middleware/jwt.js' // POST /nostr/session — authenticate with NIP-98, receive JWT authRouter.post('/nostr/session', rateLimit(60_000, 10), async (c) => { // Extract NIP-98 token from headers (try multiple header names) const authHeader = c.req.header('Authorization') || c.req.header('nostr-authorization') || c.req.header('x-nostr-authorization') if (!authHeader) { return c.json({ error: 'Missing NIP-98 authorization header' }, 401) } // Verify the NIP-98 event signature, URL, method, and freshness const requestPath = new URL(c.req.url).pathname const result = verifyNip98Token(authHeader, requestPath, 'POST') if (!result.valid || !result.pubkey) { return c.json({ error: result.error || 'NIP-98 verification failed' }, 401) } const pubkey = result.pubkey // Look up bot for this pubkey const rows = await db.select({ id: schema.bots.id, name: schema.bots.name, avatarSeed: schema.bots.avatarSeed, archetype: schema.bots.archetype, profilePicUrl: schema.bots.profilePicUrl, eloRating: schema.bots.eloRating, wins: schema.bots.wins, losses: schema.bots.losses, winStreak: schema.bots.winStreak, bestStreak: schema.bots.bestStreak, tier: schema.bots.tier, isActive: schema.bots.isActive, customization: schema.bots.customization, webhookUrl: schema.bots.webhookUrl, satsWon: schema.bots.satsWon, satsWagered: schema.bots.satsWagered, }).from(schema.bots).where(eq(schema.bots.publicKey, pubkey)).limit(1) let botData = null let botId: string | undefined if (rows.length > 0) { const bot = rows[0] botId = bot.id // Auto-upgrade: if creator logs in, ensure archetype + bot mode are correct if (isCreatorPubkey(pubkey)) { const fixes: Record = {} if (bot.archetype !== "the_creator") fixes.archetype = "the_creator" if (bot.webhookUrl === "http://human.local/") fixes.webhookUrl = "http://poll.local/" if (Object.keys(fixes).length > 0) { await db.update(schema.bots).set(fixes).where(eq(schema.bots.id, bot.id)) if (fixes.archetype) bot.archetype = "the_creator" if (fixes.webhookUrl) bot.webhookUrl = "http://poll.local/" } } const isHuman = bot.webhookUrl === 'http://human.local/' botData = { id: bot.id, name: bot.name, avatarSeed: bot.avatarSeed, archetype: bot.archetype, profilePicUrl: bot.profilePicUrl, eloRating: bot.eloRating, wins: bot.wins, losses: bot.losses, winStreak: bot.winStreak, bestStreak: bot.bestStreak, tier: bot.tier, isActive: bot.isActive, isHuman, customization: bot.customization ? JSON.parse(bot.customization) : null, satsWon: bot.satsWon ?? 0, satsWagered: bot.satsWagered ?? 0, hasWallet: false, } } else if (isCreatorPubkey(pubkey)) { // Auto-create creator const id = nanoid(12) const secret = randomBytes(32).toString('hex') await db.insert(schema.bots).values({ id, name: 'the_creator', webhookUrl: 'http://poll.local/', avatarSeed: 'the_creator', archetype: 'the_creator', secretHash: createHash('sha256').update(secret).digest('hex'), publicKey: pubkey, profilePicUrl: null, customization: null, createdAt: new Date().toISOString(), }) botId = id botData = { id, name: 'the_creator', avatarSeed: 'the_creator', archetype: 'the_creator', profilePicUrl: null, eloRating: 1200, wins: 0, losses: 0, winStreak: 0, bestStreak: 0, tier: 0, isActive: true, isHuman: false, customization: null, satsWon: 0, satsWagered: 0, hasWallet: false, } } // Issue JWT (valid for 24 hours) const token = createJwt(pubkey, botId) return c.json({ token, exists: !!botData, pubkey, bot: botData, }) }) // Regenerate bot secret (requires JWT auth — owner only) authRouter.post('/regenerate-secret', rateLimit(3_600_000, 3), async (c) => { const pubkey = extractPubkeyFromAuth(c.req.header('Authorization')) if (!pubkey) { return c.json({ error: 'Authentication required.' }, 401) } const rows = await db.select({ id: schema.bots.id, name: schema.bots.name, webhookUrl: schema.bots.webhookUrl, }) .from(schema.bots) .where(eq(schema.bots.publicKey, pubkey)) .limit(1) if (rows.length === 0) { return c.json({ error: 'No bot found for this key.' }, 404) } const bot = rows[0] const isHuman = bot.webhookUrl === 'http://human.local/' if (isHuman) { return c.json({ error: 'Human players do not use bot secrets.' }, 400) } const secret = randomBytes(32).toString('hex') await db.update(schema.bots) .set({ secretHash: createHash('sha256').update(secret).digest('hex') }) .where(eq(schema.bots.id, bot.id)) return c.json({ botId: bot.id, secret, message: 'Secret regenerated. Your old secret no longer works. Save this immediately.', }) })