diff --git a/src/components/auth/AmberCallbackHandler.tsx b/src/components/auth/AmberCallbackHandler.tsx index 50230dc..a3e4159 100644 --- a/src/components/auth/AmberCallbackHandler.tsx +++ b/src/components/auth/AmberCallbackHandler.tsx @@ -25,6 +25,8 @@ import { getAmberPendingOperationAsync, clearAmberPendingOperation, cleanAmberCallbackUrl, + getAmberLogin, + reconstructSignedEvent, } from '@/lib/amber'; export function AmberCallbackHandler() { @@ -163,12 +165,18 @@ export function AmberCallbackHandler() { } case 'sign_event': { - // Store the signed event result for the caller to retrieve - // This is tricky because the original caller's promise can't be resolved - // after a page redirect. For now, we'll store it and show a toast. - try { - const signedEvent = JSON.parse(result); - // Store in sessionStorage for potential retrieval + // Store the signed event result for the caller to retrieve. + // Amber returns a BARE signature (returnType='signature'), so we + // reconstruct the full event from the original template + signature + // and compute its id locally — a raw JSON.parse(result) would throw on + // a bare signature or produce an event with an empty id that stalls + // the relay. See reconstructSignedEvent() for details. + const signedEvent = reconstructSignedEvent( + result, + pendingOp.eventJson, + getAmberLogin() ?? '', + ); + if (signedEvent) { sessionStorage.setItem('amber_signed_event', JSON.stringify(signedEvent)); clearAmberPendingOperation(); toast({ @@ -178,8 +186,8 @@ export function AmberCallbackHandler() { // Reload the page to re-submit the event // This is a workaround since we can't resolve the original promise window.location.reload(); - } catch (e) { - console.error('Failed to parse signed event:', e); + } else { + console.error('Failed to reconstruct signed event from Amber result'); clearAmberPendingOperation(); toast({ title: 'Signing failed', diff --git a/src/lib/amber.ts b/src/lib/amber.ts index dd1a51b..4719661 100644 --- a/src/lib/amber.ts +++ b/src/lib/amber.ts @@ -1,4 +1,5 @@ import type { NostrEvent, NostrSigner } from '@nostrify/nostrify'; +import { getEventHash } from 'nostr-tools'; /** * Pending Amber operation stored in multiple places for PWA resilience. @@ -178,6 +179,87 @@ export function clearAmberLogin(): void { localStorage.removeItem(AMBER_LOGIN_KEY); } +/** + * Reconstruct a fully-formed, valid signed event from Amber's callback result. + * + * WHY THIS EXISTS + * --------------- + * We ask Amber with `returnType: 'signature'`, so on most Amber builds the + * callback `result` is a BARE 128-char hex signature — NOT a full event JSON. + * The old callback handlers did `JSON.parse(result)`, which either threw on the + * bare signature ("Failed to process signed event") or, when a build returned + * partial JSON, produced an event with an empty `id` that stalls the relay + * forever. This is the same defect that hung admin deletes on native. + * + * The event id is a deterministic NIP-01 hash of the signed fields, so we + * reconstruct the event from the ORIGINAL unsigned template (persisted as + * `eventJson` on the pending op) plus the returned signature, and compute the + * id ourselves. Handles every Amber response shape: + * - bare signature string + * - full signed-event JSON + * - partial JSON missing `id` + * + * @param result the raw callback payload from Amber (signature or JSON) + * @param eventJson the original unsigned event JSON we sent to Amber (if known) + * @param pubkey the signer's pubkey (Amber's key) + * @returns a valid NostrEvent, or null if it cannot be reconstructed + */ +export function reconstructSignedEvent( + result: string, + eventJson: string | undefined, + pubkey: string, +): NostrEvent | null { + const trimmed = (result ?? '').trim(); + if (!trimmed) return null; + + // Case 1: Amber returned full (or partial) event JSON. + let parsed: Partial | null = null; + if (trimmed.startsWith('{')) { + try { + parsed = JSON.parse(trimmed) as Partial; + } catch { + parsed = null; + } + } + + // Case 2: Amber returned a bare hex signature (returnType='signature'). + const looksLikeSig = /^[0-9a-fA-F]{128}$/.test(trimmed); + + // Seed the base fields from the original template we sent to Amber. + let template: Partial = {}; + if (eventJson) { + try { + template = JSON.parse(eventJson) as Partial; + } catch { + template = {}; + } + } + + const kind = parsed?.kind ?? template.kind; + const content = parsed?.content ?? template.content; + const tags = parsed?.tags ?? template.tags; + const created_at = parsed?.created_at ?? template.created_at; + const eventPubkey = parsed?.pubkey ?? template.pubkey ?? pubkey; + const sig = parsed?.sig ?? (looksLikeSig ? trimmed : undefined); + + // Without these we cannot produce a valid event. + if ( + kind === undefined || + content === undefined || + tags === undefined || + created_at === undefined || + !sig + ) { + return null; + } + + const base = { kind, content, tags, created_at, pubkey: eventPubkey }; + const id = + parsed?.id && parsed.id.length === 64 ? parsed.id : getEventHash(base); + + return { ...base, id, sig }; +} + /** * Extract search params from both the real query string AND the hash fragment. * diff --git a/src/pages/AmberCallbackPage.tsx b/src/pages/AmberCallbackPage.tsx index bfd768d..6c2fa9f 100644 --- a/src/pages/AmberCallbackPage.tsx +++ b/src/pages/AmberCallbackPage.tsx @@ -18,6 +18,8 @@ import { getAmberPendingOperationAsync, clearAmberPendingOperation, cleanAmberCallbackUrl, + getAmberLogin, + reconstructSignedEvent, } from '@/lib/amber'; import { CheckCircle2, XCircle, Loader2, Home } from 'lucide-react'; import { Button } from '@/components/ui/button'; @@ -78,15 +80,22 @@ export default function AmberCallbackPage() { // ── sign_event ────────────────────────────────────────────────────── if (pendingOp.type === 'sign_event') { - try { - const signedEvent = JSON.parse(result); + // Amber returns a BARE signature (returnType='signature'), so we + // reconstruct the full event from the original template + signature and + // compute its id locally. See reconstructSignedEvent() for the why. + const signedEvent = reconstructSignedEvent( + result, + pendingOp.eventJson, + getAmberLogin() ?? '', + ); + if (signedEvent) { localStorage.setItem('amber_signed_event', JSON.stringify(signedEvent)); clearAmberPendingOperation(); setStatus('success'); setMessage('Event signed! Returning...'); toast({ title: 'Event signed', description: 'Your event was signed by Amber.' }); redirectBack(true); - } catch { + } else { setStatus('error'); setMessage('Failed to process signed event from Amber.'); clearAmberPendingOperation();