feat(09-02): retire the bare-pubkey session path (BOT-01)
CI / check (push) Failing after 6m11s

Client: useNostr.ts's auto-restore now calls GET /api/auth/me (a plain
authFetch, no body) instead of POSTing {pubkey} to /api/auth/login —
identity is derived server-side from the JWT alone, never claimed by
the client.

Server: POST /login is reduced to a pure, documented-deprecated read.
Removed the creator auto-create branch and the creator auto-upgrade
db.update block — an unauthenticated request can no longer mutate the
database via this endpoint. The identical creator auto-create/upgrade
logic already exists, correctly gated behind NIP-98 verification, in
POST /nostr/session, so a creator signing in with a real signer still
gets the same row created/upgraded. Added a handler doc comment plus a
new auth.test.ts case asserting an unregistered creator pubkey now
returns exists:false and leaves the bots table row count unchanged.

e2e/helpers/auth.ts: doc comments updated to describe loginWithPubkey
as a read-only test lookup helper, not a login; request/signature
unchanged so existing e2e specs keep working.

Verification: auth.test.ts + auth-edge.test.ts + auth-audit.test.ts +
auth-me.test.ts = 56/56 pass. Full server suite (bypassing pnpm's
install-gate via ./node_modules/.bin/vitest, since this environment's
pnpm needs an interactive build-approval step unrelated to this task)
= 810/817 pass, remaining 7 are pre-existing timing/perf flakes under
CPU load (lifecycle/speed-meta/tier-balance/bot-auth constant-time),
none touching auth. tsc (server) and vue-tsc (frontend) both exit 0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-07-30 22:31:26 -04:00
co-authored by Claude Fable 5
parent 2dd9947516
commit 2a343ac746
4 changed files with 47 additions and 64 deletions
+9 -5
View File
@@ -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`, {
+5 -6
View File
@@ -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)
+22
View File
@@ -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', {
+11 -53
View File
@@ -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<string, string> = {}
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({