diff --git a/e2e/helpers/auth.ts b/e2e/helpers/auth.ts index 2d214ed..6f26e57 100644 --- a/e2e/helpers/auth.ts +++ b/e2e/helpers/auth.ts @@ -1,26 +1,30 @@ /** * E2E authentication helpers. - * Provides programmatic login for tests without browser extension interaction. + * Provides a programmatic bot lookup for tests without browser extension interaction. */ import { randomPubkey } from './setup.js' /** * Create a test identity (pubkey + nsec equivalent). - * For E2E tests, we use direct pubkey-based login (legacy endpoint) + * For E2E tests, we use the read-only lookup helper below (loginWithPubkey) * since we can't interact with NIP-07 browser extensions. */ export function createTestIdentity() { return { pubkey: randomPubkey(), // In a real NIP-98 flow, this would be a signed event - // For testing, we use the legacy login endpoint + // For testing, we use the deprecated read-only lookup endpoint } } /** - * Login via legacy endpoint and get bot data. - * Returns bot info if the pubkey has a registered bot. + * Look up a bot by pubkey via the deprecated, read-only POST /api/auth/login + * endpoint. This is NOT a login — it establishes no session and issues no + * token (D-01/BOT-01). It's kept only as a test helper: real session + * establishment goes through POST /api/auth/nostr/session (NIP-98) and + * session restoration through GET /api/auth/me (JWT). Returns bot info if + * the pubkey has a registered bot, `{}` otherwise. */ export async function loginWithPubkey(baseURL: string, pubkey: string): Promise<{ bot?: { id: string; name: string } }> { const res = await fetch(`${baseURL}/api/auth/login`, { diff --git a/frontend/src/composables/useNostr.ts b/frontend/src/composables/useNostr.ts index e92a8fe..c9c6923 100644 --- a/frontend/src/composables/useNostr.ts +++ b/frontend/src/composables/useNostr.ts @@ -114,15 +114,14 @@ if (typeof document !== 'undefined') { }) } -// Auto-restore session from JWT on first load +// Auto-restore session from JWT on first load. +// Identity comes from the token alone — GET /api/auth/me derives the +// pubkey server-side via extractPubkeyFromAuth, so no bare pubkey is +// ever sent to claim a session (D-01/BOT-01). if (!autoRestoreRan && pubkey.value && !bot.value && getToken() && !isTokenExpired()) { autoRestoreRan = true; (globalThis as any).__bf_autoRestoreRan = true - authFetch('/api/auth/login', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ pubkey: pubkey.value }), - }).then(r => r.json()).then(data => { + authFetch('/api/auth/me').then(r => r.json()).then(data => { if (data.exists) { bot.value = normalizeBotData(data.bot) store('bf_bot', bot.value) diff --git a/server/src/routes/auth.test.ts b/server/src/routes/auth.test.ts index 57c931f..da65743 100644 --- a/server/src/routes/auth.test.ts +++ b/server/src/routes/auth.test.ts @@ -2,6 +2,10 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { Hono } from 'hono' import { authRouter } from './auth.js' import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools' +import { db, schema } from '../db/index.js' +import { eq } from 'drizzle-orm' + +const CREATOR_PUBKEY = 'da5e0c1b646bdb13c2300f805b0ca3e5afe5b052c594ce78bac8978d21c3fa39' const app = new Hono() app.route('/api/auth', authRouter) @@ -72,6 +76,24 @@ describe('auth routes', () => { expect(body.pubkey).toBe(pk) }) + it('login: an unregistered creator pubkey returns exists=false and creates no row (auto-create removed — D-01)', async () => { + const before = await db.select({ id: schema.bots.id }).from(schema.bots) + .where(eq(schema.bots.publicKey, CREATOR_PUBKEY)) + + const res = await app.request('/api/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ pubkey: CREATOR_PUBKEY }), + }) + expect(res.status).toBe(200) + const body = await res.json() as { exists: boolean; pubkey?: string } + expect(body.exists).toBe(false) + + const after = await db.select({ id: schema.bots.id }).from(schema.bots) + .where(eq(schema.bots.publicKey, CREATOR_PUBKEY)) + expect(after.length).toBe(before.length) + }) + // --- register --- it('register: rejects invalid pubkey', async () => { const res = await app.request('/api/auth/register', { diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts index 971ae5e..3663e8c 100644 --- a/server/src/routes/auth.ts +++ b/server/src/routes/auth.ts @@ -89,7 +89,17 @@ authRouter.get('/me', async (c) => { }) }) -// Login with Nostr pubkey (rate limited: 10 per minute per IP) +// 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) { @@ -117,62 +127,10 @@ authRouter.post('/login', rateLimit(60_000, 10), async (c) => { }).from(schema.bots).where(eq(schema.bots.publicKey, pubkey)).limit(1) if (rows.length === 0) { - // Auto-create bot for the Creator if not registered - if (isCreatorPubkey(pubkey)) { - 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(), - }) - 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: false, - customization: null, - satsWon: 0, - satsWagered: 0, - hasWallet: false, - }, - }) - } return c.json({ exists: false, pubkey }) } const bot = rows[0] - - // 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/' return c.json({