import type { NostrEvent, NostrSigner } from '@nostrify/nostrify'; import { getEventHash } from 'nostr-tools'; /** * Pending Amber operation stored in multiple places for PWA resilience. * Used to track operations that are waiting for Amber to return. */ export interface AmberPendingOperation { type: 'get_public_key' | 'sign_event' | 'nip04_encrypt' | 'nip04_decrypt' | 'nip44_encrypt' | 'nip44_decrypt'; /** Unique ID for the operation */ id: string; /** The unsigned event JSON (for sign_event) */ eventJson?: string; /** Timestamp when operation was initiated */ timestamp: number; /** The pubkey to encrypt/decrypt with (for nip04/nip44 operations) */ targetPubkey?: string; /** The plaintext to encrypt (for encrypt operations) */ plaintext?: string; /** The ciphertext to decrypt (for decrypt operations) */ ciphertext?: string; } const AMBER_PENDING_KEY = 'amber_pending_operation'; const AMBER_LOGIN_KEY = 'amber_login'; /** * Store pending operation info for when Amber returns. * Uses multiple storage mechanisms for PWA resilience. */ export function setAmberPendingOperation(operation: AmberPendingOperation): void { const json = JSON.stringify(operation); // Store in multiple places for resilience (PWAs can lose localStorage on redirect) try { localStorage.setItem(AMBER_PENDING_KEY, json); } catch (e) { console.warn('[Amber] Failed to store in localStorage:', e); } try { sessionStorage.setItem(AMBER_PENDING_KEY, json); } catch (e) { console.warn('[Amber] Failed to store in sessionStorage:', e); } // Also store in IndexedDB for maximum persistence try { const request = indexedDB.open('amber_store', 1); request.onupgradeneeded = () => { const db = request.result; if (!db.objectStoreNames.contains('pending')) { db.createObjectStore('pending'); } }; request.onsuccess = () => { const db = request.result; const tx = db.transaction('pending', 'readwrite'); tx.objectStore('pending').put(json, AMBER_PENDING_KEY); }; } catch (e) { console.warn('[Amber] Failed to store in IndexedDB:', e); } } /** * Get pending operation from any available storage. */ export function getAmberPendingOperation(): AmberPendingOperation | null { // Try localStorage first let stored = localStorage.getItem(AMBER_PENDING_KEY); // Fall back to sessionStorage if (!stored) { stored = sessionStorage.getItem(AMBER_PENDING_KEY); if (stored) { console.log('[Amber] Found pending op in sessionStorage (localStorage was empty)'); } } if (!stored) return null; try { return JSON.parse(stored) as AmberPendingOperation; } catch { return null; } } /** * Get pending operation from IndexedDB (async). */ export async function getAmberPendingOperationAsync(): Promise { // First try sync storage const syncResult = getAmberPendingOperation(); if (syncResult) return syncResult; // Try IndexedDB return new Promise((resolve) => { try { const request = indexedDB.open('amber_store', 1); request.onupgradeneeded = () => { const db = request.result; if (!db.objectStoreNames.contains('pending')) { db.createObjectStore('pending'); } }; request.onsuccess = () => { const db = request.result; if (!db.objectStoreNames.contains('pending')) { resolve(null); return; } const tx = db.transaction('pending', 'readonly'); const getReq = tx.objectStore('pending').get(AMBER_PENDING_KEY); getReq.onsuccess = () => { if (getReq.result) { console.log('[Amber] Found pending op in IndexedDB'); try { resolve(JSON.parse(getReq.result)); } catch { resolve(null); } } else { resolve(null); } }; getReq.onerror = () => resolve(null); }; request.onerror = () => resolve(null); } catch { resolve(null); } }); } /** * Clear the pending operation from all storage. */ export function clearAmberPendingOperation(): void { try { localStorage.removeItem(AMBER_PENDING_KEY); } catch { /* ignore */ } try { sessionStorage.removeItem(AMBER_PENDING_KEY); } catch { /* ignore */ } try { const request = indexedDB.open('amber_store', 1); request.onsuccess = () => { const db = request.result; if (db.objectStoreNames.contains('pending')) { const tx = db.transaction('pending', 'readwrite'); tx.objectStore('pending').delete(AMBER_PENDING_KEY); } }; } catch { /* ignore */ } } /** * Store Amber login (pubkey). */ export function setAmberLogin(pubkey: string): void { localStorage.setItem(AMBER_LOGIN_KEY, pubkey); } /** * Get stored Amber login pubkey. */ export function getAmberLogin(): string | null { return localStorage.getItem(AMBER_LOGIN_KEY); } /** * Clear Amber login. */ 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. * * With HashRouter the URL looks like: * https://localhost/#/amber-callback?event=XXXX * In that case `window.location.search` is empty but the params are embedded * inside `window.location.hash` after the path segment. */ function getSearchParams(): URLSearchParams { // Normal query string (PWA / browser) if (window.location.search) { return new URLSearchParams(window.location.search); } // HashRouter: extract the query string portion from the hash const hash = window.location.hash; // e.g. "#/amber-callback?event=XXX" const qIndex = hash.indexOf('?'); if (qIndex !== -1) { return new URLSearchParams(hash.slice(qIndex)); } return new URLSearchParams(); } /** * Check if the current page was opened as an Amber callback. * Also checks pre-captured data from main.tsx for PWA resilience. */ export function isAmberCallback(): boolean { const params = getSearchParams(); if (params.has('event')) return true; // Check pre-captured data (PWAs might lose URL params during hydration) const preCaptured = sessionStorage.getItem('amber_callback_event'); if (preCaptured) { console.log('[Amber] Found pre-captured callback data'); return true; } return false; } /** * Get the result from Amber callback URL. * Falls back to pre-captured data from main.tsx. */ export function getAmberCallbackResult(): string | null { const params = getSearchParams(); const fromUrl = params.get('event'); if (fromUrl) { console.log('[Amber] Got result from URL params:', fromUrl); return fromUrl; } // Check pre-captured data const preCaptured = sessionStorage.getItem('amber_callback_event'); if (preCaptured) { console.log('[Amber] Got result from pre-captured data:', preCaptured); // Clear it so we don't process it again sessionStorage.removeItem('amber_callback_event'); sessionStorage.removeItem('amber_callback_url'); sessionStorage.removeItem('amber_callback_timestamp'); return preCaptured; } return null; } /** * Clean the URL after processing Amber callback. * With HashRouter we replace the hash to remove the query params. */ export function cleanAmberCallbackUrl(): void { if (window.location.hash.includes('?')) { // HashRouter: strip query from hash fragment const hashPath = window.location.hash.split('?')[0]; window.history.replaceState({}, '', window.location.pathname + hashPath); } else { const cleanUrl = window.location.origin + window.location.pathname; window.history.replaceState({}, '', cleanUrl); } } /** * Generate a unique operation ID. */ function generateOperationId(): string { return `amber_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`; } /** * Build the dedicated Amber callback URL. * * This app uses HashRouter, so ALL in-app routes live inside the hash fragment: * https://skate-spots.org/#/amber-callback * * Amber will open the callbackUrl in the browser after the user approves. * The URL must therefore use the hash-based path so React Router can match * the /amber-callback route and render AmberCallbackPage. * * In the Capacitor APK we must use the app's custom URI scheme so Android fires * an intent back into the app (not a browser navigation). * * The app ID is `org.skatespots.app` — Capacitor registers this as a URI scheme * automatically via the `appId` in capacitor.config.ts. */ function buildCallbackUrl(): string { const isCapacitor = !!(window as unknown as { Capacitor?: { isNativePlatform?: () => boolean } }).Capacitor?.isNativePlatform?.(); if (isCapacitor) { return 'org.skatespots.app://amber-callback?event='; } // IMPORTANT: The callbackUrl is embedded RAW inside an intent: string that uses // # as a delimiter (intent:#Intent;...). So the callbackUrl CANNOT // contain a # character — it would break the intent format. // // We use a plain URL without the hash fragment: // https://skate-spots.org/amber-callback?event= // // The server (or Netlify/nsite) serves index.html for all paths, so the app // loads at /amber-callback. main.tsx pre-captures ?event= before React hydrates, // and AmberCallbackPage processes it via sessionStorage. return `${window.location.origin}/amber-callback?event=`; } /** * Navigate to an Amber intent URL. */ function openAmberUrl(url: string): void { window.location.href = url; } /** * Build an Android intent URL for Amber — the ONLY format that works from a browser. * * Per Amber README (https://github.com/greenart7c3/Amber) web apps section, * the exact format is: * * intent:#Intent;scheme=nostrsigner;S.compressionType=none;S.returnType=signature;S.type=get_public_key;S.callbackUrl=https://example.com/?event=;end * * CRITICAL RULES: * 1. Use intent: scheme, NOT nostrsigner: directly * 2. Params go as S.= separated by semicolons * 3. callbackUrl is NOT encoded — it is raw, including the trailing ?event= * 4. payload (event JSON etc.) goes BEFORE the # and must be encodeURIComponent'd * 5. window.href (not window.location.href, not window.open) per the spec */ function buildAmberUrl(payload: string, params: Record): string { const extras = Object.entries(params) .map(([k, v]) => `S.${k}=${v}`) .join(';'); return `intent:${payload}#Intent;scheme=nostrsigner;${extras};end`; } /** * Open Amber for get_public_key WITHOUT a callback URL. * Used in PWA/Capacitor mode: Amber copies pubkey to clipboard, * the app reads it on visibilitychange. */ export function openAmberForLoginNoCallback(): void { const amberUrl = buildAmberUrl('', { compressionType: 'none', returnType: 'signature', type: 'get_public_key', }); openAmberUrl(amberUrl); } /** * Open Amber for get_public_key operation. * Uses a dedicated /amber-callback route so the app state is preserved * and the callback is processed reliably on return. */ export function openAmberForLogin(): void { const operationId = generateOperationId(); const callbackUrl = buildCallbackUrl(); const pendingOp = { type: 'get_public_key' as const, id: operationId, timestamp: Date.now(), }; setAmberPendingOperation(pendingOp); // Store the current hash path so AmberCallbackPage can navigate back here // after login (e.g. user was on /map and tapped Login → returns to /map). const currentPath = window.location.hash.replace(/^#/, '') || '/'; localStorage.setItem('amber_return_path', currentPath); // Android intent URL for Amber — callbackUrl is RAW (not encoded) per Amber README const amberUrl = buildAmberUrl('', { compressionType: 'none', returnType: 'signature', type: 'get_public_key', callbackUrl: callbackUrl, }); console.log('[Amber] Opening Amber for login, intent:', amberUrl); openAmberUrl(amberUrl); } /** * Subscribe to app becoming visible again after user switches back from Amber. * Calls `onReturn` once when the page regains visibility. * Returns an unsubscribe function. * * Usage: * const unsub = subscribeToAmberReturn(() => checkClipboardForPubkey()); */ export function subscribeToAmberReturn(onReturn: () => void): () => void { const handler = () => { if (document.visibilityState === 'visible') { document.removeEventListener('visibilitychange', handler); onReturn(); } }; document.addEventListener('visibilitychange', handler); return () => document.removeEventListener('visibilitychange', handler); } /** * Open Amber for sign_event operation. * Returns a promise that resolves when user returns to the page. * The actual result needs to be processed on page load. */ export function openAmberForSignEvent(event: Omit): void { const operationId = generateOperationId(); const callbackUrl = buildCallbackUrl(); const eventJson = JSON.stringify(event); setAmberPendingOperation({ type: 'sign_event', id: operationId, eventJson, timestamp: Date.now(), }); const amberUrl = buildAmberUrl(encodeURIComponent(eventJson), { type: 'sign_event', compressionType: 'none', returnType: 'event', callbackUrl: callbackUrl, }); openAmberUrl(amberUrl); } /** * Open Amber for NIP-04 encrypt operation. */ export function openAmberForNip04Encrypt(pubkey: string, plaintext: string): void { const operationId = generateOperationId(); const callbackUrl = buildCallbackUrl(); setAmberPendingOperation({ type: 'nip04_encrypt', id: operationId, targetPubkey: pubkey, plaintext, timestamp: Date.now(), }); const amberUrl = buildAmberUrl(encodeURIComponent(plaintext), { type: 'nip04_encrypt', pubKey: pubkey, compressionType: 'none', returnType: 'signature', callbackUrl: callbackUrl, }); openAmberUrl(amberUrl); } /** * Open Amber for NIP-04 decrypt operation. */ export function openAmberForNip04Decrypt(pubkey: string, ciphertext: string): void { const operationId = generateOperationId(); const callbackUrl = buildCallbackUrl(); setAmberPendingOperation({ type: 'nip04_decrypt', id: operationId, targetPubkey: pubkey, ciphertext, timestamp: Date.now(), }); const amberUrl = buildAmberUrl(encodeURIComponent(ciphertext), { type: 'nip04_decrypt', pubKey: pubkey, compressionType: 'none', returnType: 'signature', callbackUrl: callbackUrl, }); openAmberUrl(amberUrl); } /** * Open Amber for NIP-44 encrypt operation. */ export function openAmberForNip44Encrypt(pubkey: string, plaintext: string): void { const operationId = generateOperationId(); const callbackUrl = buildCallbackUrl(); setAmberPendingOperation({ type: 'nip44_encrypt', id: operationId, targetPubkey: pubkey, plaintext, timestamp: Date.now(), }); const amberUrl = buildAmberUrl(encodeURIComponent(plaintext), { type: 'nip44_encrypt', pubKey: pubkey, compressionType: 'none', returnType: 'signature', callbackUrl: callbackUrl, }); openAmberUrl(amberUrl); } /** * Open Amber for NIP-44 decrypt operation. */ export function openAmberForNip44Decrypt(pubkey: string, ciphertext: string): void { const operationId = generateOperationId(); const callbackUrl = buildCallbackUrl(); setAmberPendingOperation({ type: 'nip44_decrypt', id: operationId, targetPubkey: pubkey, ciphertext, timestamp: Date.now(), }); const amberUrl = buildAmberUrl(encodeURIComponent(ciphertext), { type: 'nip44_decrypt', pubKey: pubkey, compressionType: 'none', returnType: 'signature', callbackUrl: callbackUrl, }); openAmberUrl(amberUrl); } /** * Decode a gzip-compressed Amber result (prefixed with "Signer1"). * Falls back to treating as plain text if not compressed. */ export function decodeAmberResult(result: string): string { if (result.startsWith('Signer1')) { // Gzip compressed result const base64 = result.substring(7); // Remove "Signer1" prefix try { const binary = atob(base64); const bytes = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i++) { bytes[i] = binary.charCodeAt(i); } // Use DecompressionStream if available const ds = new DecompressionStream('gzip'); const writer = ds.writable.getWriter(); writer.write(bytes); writer.close(); const reader = ds.readable.getReader(); const chunks: Uint8Array[] = []; const readChunks = async (): Promise => { const { done, value } = await reader.read(); if (done) { const combined = new Uint8Array(chunks.reduce((acc, c) => acc + c.length, 0)); let offset = 0; for (const chunk of chunks) { combined.set(chunk, offset); offset += chunk.length; } return new TextDecoder().decode(combined); } chunks.push(value); return readChunks(); }; // For sync usage, we'll just return the raw result // The caller should handle async decompression if needed console.warn('Gzip decompression requires async handling'); return result; } catch (e) { console.error('Failed to decode gzip result:', e); return result; } } // Plain text result - might be URL encoded try { return decodeURIComponent(result); } catch { return result; } } /** * AmberSigner implements the NostrSigner interface for Amber. * * Note: Due to the nature of web-to-Amber communication via URL redirects, * signing operations will redirect the user to Amber and require them to * return to the app. This means sign() cannot return synchronously. * * For a better UX, consider using NIP-46 (Nostr Connect / Bunker) instead. */ export class AmberSigner implements NostrSigner { readonly pubkey: string; constructor(pubkey: string) { this.pubkey = pubkey; } async getPublicKey(): Promise { return this.pubkey; } // NOTE: Nostrify's NostrSigner interface requires `signEvent` (not `sign`). // The old method was named `sign`, so every `user.signer.signEvent(...)` call // on a web/PWA Amber login threw "signer.signEvent is not a function". This // now correctly implements the interface. On web, signing genuinely requires // a redirect round-trip to Amber, so this navigates away and the result is // processed on return by AmberCallbackPage. async signEvent(event: Omit): Promise { // Store the current state and redirect to Amber // The result will be processed on page reload openAmberForSignEvent(event); // This promise will never resolve in the current page context // because we're redirecting to Amber return new Promise(() => { // The page will redirect, so this never resolves }); } readonly nip04 = { encrypt: async (pubkey: string, plaintext: string): Promise => { openAmberForNip04Encrypt(pubkey, plaintext); return new Promise(() => {}); }, decrypt: async (pubkey: string, ciphertext: string): Promise => { openAmberForNip04Decrypt(pubkey, ciphertext); return new Promise(() => {}); }, }; readonly nip44 = { encrypt: async (pubkey: string, plaintext: string): Promise => { openAmberForNip44Encrypt(pubkey, plaintext); return new Promise(() => {}); }, decrypt: async (pubkey: string, ciphertext: string): Promise => { openAmberForNip44Decrypt(pubkey, ciphertext); return new Promise(() => {}); }, }; }