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:
+9
-5
@@ -1,26 +1,30 @@
|
|||||||
/**
|
/**
|
||||||
* E2E authentication helpers.
|
* 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'
|
import { randomPubkey } from './setup.js'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a test identity (pubkey + nsec equivalent).
|
* 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.
|
* since we can't interact with NIP-07 browser extensions.
|
||||||
*/
|
*/
|
||||||
export function createTestIdentity() {
|
export function createTestIdentity() {
|
||||||
return {
|
return {
|
||||||
pubkey: randomPubkey(),
|
pubkey: randomPubkey(),
|
||||||
// In a real NIP-98 flow, this would be a signed event
|
// 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.
|
* Look up a bot by pubkey via the deprecated, read-only POST /api/auth/login
|
||||||
* Returns bot info if the pubkey has a registered bot.
|
* 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 } }> {
|
export async function loginWithPubkey(baseURL: string, pubkey: string): Promise<{ bot?: { id: string; name: string } }> {
|
||||||
const res = await fetch(`${baseURL}/api/auth/login`, {
|
const res = await fetch(`${baseURL}/api/auth/login`, {
|
||||||
|
|||||||
@@ -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()) {
|
if (!autoRestoreRan && pubkey.value && !bot.value && getToken() && !isTokenExpired()) {
|
||||||
autoRestoreRan = true;
|
autoRestoreRan = true;
|
||||||
(globalThis as any).__bf_autoRestoreRan = true
|
(globalThis as any).__bf_autoRestoreRan = true
|
||||||
authFetch('/api/auth/login', {
|
authFetch('/api/auth/me').then(r => r.json()).then(data => {
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ pubkey: pubkey.value }),
|
|
||||||
}).then(r => r.json()).then(data => {
|
|
||||||
if (data.exists) {
|
if (data.exists) {
|
||||||
bot.value = normalizeBotData(data.bot)
|
bot.value = normalizeBotData(data.bot)
|
||||||
store('bf_bot', bot.value)
|
store('bf_bot', bot.value)
|
||||||
|
|||||||
@@ -2,6 +2,10 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|||||||
import { Hono } from 'hono'
|
import { Hono } from 'hono'
|
||||||
import { authRouter } from './auth.js'
|
import { authRouter } from './auth.js'
|
||||||
import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools'
|
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()
|
const app = new Hono()
|
||||||
app.route('/api/auth', authRouter)
|
app.route('/api/auth', authRouter)
|
||||||
@@ -72,6 +76,24 @@ describe('auth routes', () => {
|
|||||||
expect(body.pubkey).toBe(pk)
|
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 ---
|
// --- register ---
|
||||||
it('register: rejects invalid pubkey', async () => {
|
it('register: rejects invalid pubkey', async () => {
|
||||||
const res = await app.request('/api/auth/register', {
|
const res = await app.request('/api/auth/register', {
|
||||||
|
|||||||
+11
-53
@@ -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) => {
|
authRouter.post('/login', rateLimit(60_000, 10), async (c) => {
|
||||||
const parsed = loginSchema.safeParse(await c.req.json().catch(() => ({})))
|
const parsed = loginSchema.safeParse(await c.req.json().catch(() => ({})))
|
||||||
if (!parsed.success) {
|
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)
|
}).from(schema.bots).where(eq(schema.bots.publicKey, pubkey)).limit(1)
|
||||||
|
|
||||||
if (rows.length === 0) {
|
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 })
|
return c.json({ exists: false, pubkey })
|
||||||
}
|
}
|
||||||
|
|
||||||
const bot = rows[0]
|
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/'
|
const isHuman = bot.webhookUrl === 'http://human.local/'
|
||||||
|
|
||||||
return c.json({
|
return c.json({
|
||||||
|
|||||||
Reference in New Issue
Block a user