feat: add NIP-55 Android signer support for Amber/Primal login

When window.nostr isn't available (common on mobile Chrome where
extensions can't inject), fall back to NIP-55 nostrsigner: intent
URIs. This opens Amber/Primal directly to sign a NIP-98 event,
then redirects back with the signed event for JWT authentication.

- Build nostrsigner: URI with unsigned NIP-98 event + callback URL
- Process NIP-55 callback on page mount (extract signed event from URL)
- Auto-detect Android to show "SIGN IN WITH AMBER / PRIMAL" label
- Reduced window.nostr polling from 3s to 2s before NIP-55 fallback

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-09 16:15:16 +00:00
co-authored by Claude Opus 4.6
parent df70f5f093
commit 63cc00fcb6
2 changed files with 116 additions and 4 deletions
+88
View File
@@ -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<string, unknown>, 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,
}
}