diff --git a/frontend/src/composables/useNostr.ts b/frontend/src/composables/useNostr.ts index 83b309b..25a99d0 100644 --- a/frontend/src/composables/useNostr.ts +++ b/frontend/src/composables/useNostr.ts @@ -4,6 +4,17 @@ import { bytesToHex, hexToBytes } from 'nostr-tools/utils' import { nsecEncode } from 'nostr-tools/nip19' import { buildNip98Token, setToken, getToken, isTokenExpired, authFetch } from '../lib/nostr-auth' +/** NIP-55: Build a nostrsigner: URI for sign_event (inline since nostr-tools doesn't export nip55) */ +function buildSignEventUri(eventJson: Record, callbackUrl: string): string { + const params = new URLSearchParams({ + type: 'sign_event', + callbackUrl, + returnType: 'event', + compressionType: 'none', + }) + return `nostrsigner:${encodeURIComponent(JSON.stringify(eventJson))}?${params.toString()}` +} + interface BotCustomization { archetype?: string primaryColor?: string @@ -425,6 +436,80 @@ export function useNostr() { return localStorage.getItem('bf_nsec') } + /** + * NIP-55: Initiate login via Android signer app (Amber/Primal). + * Builds an unsigned NIP-98 event and navigates to a nostrsigner: URI. + * The signer app signs the event and redirects back to callbackUrl. + */ + function initiateNip55Login() { + const sessionUrl = new URL('/api/auth/nostr/session', window.location.origin).toString() + const unsignedEvent = { + kind: 27235, + tags: [['u', sessionUrl], ['method', 'POST']], + content: '', + created_at: Math.floor(Date.now() / 1000), + } + + const callbackUrl = new URL('/join-bout', window.location.origin).toString() + + const uri = buildSignEventUri(unsignedEvent, callbackUrl) + + window.location.href = uri + } + + /** + * NIP-55: Process the callback after returning from an Android signer. + * Parses the signed event from the URL and authenticates with the server. + * Returns null if no NIP-55 callback params are present. + */ + async function processNip55Return(): Promise<{ pubkey: string; bot: BotData | null } | null> { + const params = new URLSearchParams(window.location.search) + + // Look for the signed event in common NIP-55 callback param names + const eventStr = params.get('event') || params.get('result') || params.get('signed_event') + if (!eventStr) return null + + // Clean the URL so the params don't persist + window.history.replaceState({}, '', window.location.pathname) + + try { + const signedEvent = JSON.parse(decodeURIComponent(eventStr)) + const nip98Token = btoa(JSON.stringify(signedEvent)) + + const res = await fetch('/api/auth/nostr/session', { + method: 'POST', + headers: { 'Authorization': `Nostr ${nip98Token}` }, + }) + + if (!res.ok) { + const data = await res.json().catch(() => ({ error: 'Authentication failed' })) + throw new Error(data.error || 'NIP-98 authentication failed') + } + + const data = await res.json() + + setToken(data.token) + pubkey.value = data.pubkey + store('bf_pubkey', data.pubkey) + + if (data.exists && data.bot) { + bot.value = normalizeBotData(data.bot) + store('bf_bot', bot.value) + return { pubkey: data.pubkey, bot: bot.value } + } + + bot.value = null + store('bf_bot', null) + return { pubkey: data.pubkey, bot: null } + } catch (e) { + console.error('[NIP-55] Failed to process signer callback:', e) + throw e + } + } + + /** Detect if Android signer apps (Amber/Primal) might be available */ + const hasAndroidSigner = computed(() => /android/i.test(navigator.userAgent)) + return { pubkey: readonly(pubkey), bot: readonly(bot), @@ -444,6 +529,9 @@ export function useNostr() { getStoredNsec, logout, fetchNostrProfile, + initiateNip55Login, + processNip55Return, + hasAndroidSigner, } } diff --git a/frontend/src/pages/JoinBoutPage.vue b/frontend/src/pages/JoinBoutPage.vue index 1c6a2a8..216b7e9 100644 --- a/frontend/src/pages/JoinBoutPage.vue +++ b/frontend/src/pages/JoinBoutPage.vue @@ -10,7 +10,7 @@ import WalletConnect from '../components/WalletConnect.vue' import { authFetch } from '../lib/nostr-auth' const router = useRouter() -const { pubkey, bot, profilePicUrl, isLoggedIn, hasExtension, hasStoredKey, isLoading, login, waitForSigner, generateLogin, loginWithNsec, registerBot, registerHuman, getStoredNsec, logout } = useNostr() +const { pubkey, bot, profilePicUrl, isLoggedIn, hasExtension, hasStoredKey, isLoading, login, waitForSigner, generateLogin, loginWithNsec, registerBot, registerHuman, getStoredNsec, logout, initiateNip55Login, processNip55Return, hasAndroidSigner } = useNostr() const showNsecBackup = ref(false) const generatedNsec = ref('') const nsecInput = ref('') @@ -131,7 +131,25 @@ function startRateLimitTimer(msg: string) { }, 1000) } -onMounted(() => { +onMounted(async () => { + // Check for NIP-55 callback (returning from Amber/Primal signer) + try { + const nip55Result = await processNip55Return() + if (nip55Result) { + if (nip55Result.bot) { + isHumanMode.value = !!nip55Result.bot.isHuman + step.value = 'ready' + } else { + step.value = 'choose-mode' + } + pollQueue() + pollHandle = setInterval(pollQueue, 3000) + return + } + } catch (e) { + error.value = 'Signer login failed. Try again.' + } + // If already logged in with a bot, go straight to ready if (isLoggedIn.value) { step.value = 'ready' @@ -189,8 +207,14 @@ async function handleSignerLogin() { if (!window.nostr) { // Mobile signers inject late — wait briefly error.value = 'Looking for signer...' - const found = await waitForSigner(3000) + const found = await waitForSigner(2000) if (!found) { + // On Android, try NIP-55 intent to open Amber/Primal directly + if (hasAndroidSigner.value) { + error.value = 'Opening signer app...' + initiateNip55Login() + return + } error.value = 'No Nostr signer detected. Install a NIP-07 extension (Nos2x, Alby) or use Amber on Android.' return } @@ -588,7 +612,7 @@ function handleSignOut() { @click="handleSignerLogin" > - {{ isLoading ? 'CONNECTING...' : 'USE NOSTR SIGNER' }} + {{ isLoading ? 'CONNECTING...' : hasAndroidSigner ? 'SIGN IN WITH AMBER / PRIMAL' : 'USE NOSTR SIGNER' }}