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' export const authRouter = new Hono() // Login with Nostr pubkey authRouter.post('/login', async (c) => { const body = await c.req.json() const { pubkey } = body if (!pubkey || typeof pubkey !== 'string' || pubkey.length !== 64) { return c.json({ error: 'Invalid pubkey.' }, 400) } 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, }).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] // Check if this is a human player by loading webhookUrl const webhookRows = await db.select({ webhookUrl: schema.bots.webhookUrl }) .from(schema.bots).where(eq(schema.bots.id, bot.id)).limit(1) const isHuman = webhookRows[0]?.webhookUrl === 'http://human.local/' return c.json({ exists: true, bot: { ...bot, isHuman, customization: bot.customization ? JSON.parse(bot.customization) : null, }, }) }) // Register a new bot with Nostr pubkey authRouter.post('/register', rateLimit(3600_000, 15), async (c) => { const body = await c.req.json() const { pubkey, name, webhookUrl, archetype, profilePicUrl, customization: rawCustomization } = body if (!pubkey || typeof pubkey !== 'string' || pubkey.length !== 64) { return c.json({ error: 'Invalid pubkey.' }, 400) } if (!name || typeof name !== 'string' || name.length < 2 || name.length > 12) { return c.json({ error: 'Name must be 2-12 characters.' }, 400) } if (!/^[a-zA-Z0-9_-]+$/.test(name)) { return c.json({ error: 'Name must be alphanumeric, hyphens, or underscores.' }, 400) } // Validate customization const custResult = validateCustomization(rawCustomization) if (!custResult.valid) { return c.json({ error: custResult.error }, 400) } const normalizedName = name.toLowerCase() if (!webhookUrl || typeof webhookUrl !== 'string') { return c.json({ error: 'webhookUrl is required.' }, 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 const testResult = await testWebhook(webhookUrl) if (!testResult.reachable || !testResult.validResponse) { return c.json({ error: 'Webhook verification failed.', details: testResult.error || 'Webhook must return {"answer": "..."} as JSON.', latencyMs: testResult.latencyMs, }, 422) } const id = nanoid(12) const secret = randomBytes(32).toString('hex') const effectiveArchetype = custResult.data.archetype || archetype || 'standard' const custJson = Object.keys(custResult.data).length > 0 ? JSON.stringify(custResult.data) : null await db.insert(schema.bots).values({ id, name: normalizedName, webhookUrl, 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, archetype: effectiveArchetype, customization: custResult.data, webhookLatencyMs: testResult.latencyMs, message: 'Bot registered. Webhook verified.', }, 201) }) // Register a human player (no webhook required) authRouter.post('/register-human', rateLimit(3600_000, 15), async (c) => { const body = await c.req.json() const { pubkey, name, profilePicUrl, avatarSeed } = body if (!pubkey || typeof pubkey !== 'string' || pubkey.length !== 64) { return c.json({ error: 'Invalid pubkey.' }, 400) } if (!name || typeof name !== 'string' || name.length < 2 || name.length > 12) { return c.json({ error: 'Name must be 2-12 characters.' }, 400) } if (!/^[a-zA-Z0-9_-]+$/.test(name)) { return c.json({ error: 'Name must be alphanumeric, hyphens, or underscores.' }, 400) } 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') await db.insert(schema.bots).values({ id, name: normalizedName, webhookUrl: 'http://human.local/', avatarSeed: avatarSeed || normalizedName, archetype: 'human', 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: 'human', isHuman: true, message: 'Human fighter registered.', }, 201) }) // Update bot webhook and/or customization (requires pubkey match) authRouter.post('/update', async (c) => { const body = await c.req.json() const { pubkey, webhookUrl, profilePicUrl, customization: rawCustomization } = body if (!pubkey || typeof pubkey !== 'string') { return c.json({ error: 'Invalid pubkey.' }, 400) } 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 (!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) 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 if (custResult.data.archetype) { 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 }) })