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() // The Creator — game founder pubkey (auto-assigns the_creator archetype) const CREATOR_PUBKEY = "da5e0c1b646bdb13c2300f805b0ca3e5afe5b052c594ce78bac8978d21c3fa39" // 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 }) }) // Login with Nostr pubkey (rate limited: 30 per minute per IP) authRouter.post('/login', rateLimit(60_000, 30), 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, 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) { // Auto-create human fighter for the Creator if not registered if (pubkey === CREATOR_PUBKEY) { const id = nanoid(12) const secret = randomBytes(32).toString('hex') await db.insert(schema.bots).values({ id, name: 'the_creator', webhookUrl: 'http://human.local/', avatarSeed: 'the_creator', archetype: 'the_creator', secretHash: createHash('sha256').update(secret).digest('hex'), publicKey: pubkey, profilePicUrl: null, customization: null, createdAt: new Date().toISOString(), }) return c.json({ exists: true, bot: { 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: true, customization: null, satsWon: 0, satsWagered: 0, hasWallet: false, }, }) } return c.json({ exists: false, pubkey }) } const bot = rows[0] const isHuman = bot.webhookUrl === 'http://human.local/' // Auto-upgrade: if creator logs in, ensure archetype is always the_creator if (pubkey === CREATOR_PUBKEY && bot.archetype !== "the_creator") { await db.update(schema.bots).set({ archetype: "the_creator" }).where(eq(schema.bots.id, bot.id)) bot.archetype = "the_creator" } 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 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 baseArchetype = custResult.data.archetype || archetype || 'standard' const effectiveArchetype = pubkey === CREATOR_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, 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(600_000, 10), 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') const humanArchetype = pubkey === CREATOR_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 body = await c.req.json() const { pubkey, webhookUrl, profilePicUrl, customization: rawCustomization } = 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, 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 && pubkey !== CREATOR_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 }) })