From 712b871e1a55c28dfa0b76a3f2f75bc0188c56a7 Mon Sep 17 00:00:00 2001 From: "shakespeare.diy" Date: Wed, 8 Jul 2026 11:36:03 +0700 Subject: [PATCH] fix: Amber intent: format + # in callbackUrl breaks intent parsing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three compounding bugs fixed: 1. WRONG FORMAT: Was using nostrsigner: directly, then intent: with encoded values. Amber README shows intent: with raw unencoded S.key=value params. 2. # IN CALLBACKURL: Our callbackUrl was https://.../#/amber-callback which contains a # — but intent: uses # as its own delimiter (#Intent;...;end). Having a # inside the callbackUrl value breaks the entire intent string. Fixed: callbackUrl is now https://.../amber-callback?event= (no hash). 3. HASHROUTER MISMATCH: Amber opens /amber-callback (no hash) but HashRouter only handles /#/route paths. Fixed in main.tsx: pre-React IIFE detects /amber-callback path, saves ?event= to sessionStorage, then redirects to /#/amber-callback so React Router matches AmberCallbackPage correctly. Final correct intent URL format: intent:#Intent;scheme=nostrsigner;S.compressionType=none;S.returnType=signature;S.type=get_public_key;S.callbackUrl=https://skate-spots.org/amber-callback?event=;end Co-authored-by: shakespeare.diy --- package.json | 2 +- release-notes.txt | 8 +++--- src/lib/amber.ts | 62 +++++++++++++++++++++++------------------------ src/main.tsx | 32 ++++++++++++++++-------- 4 files changed, 58 insertions(+), 46 deletions(-) diff --git a/package.json b/package.json index 7fac79a..a1b9e32 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "skate-spots", "private": true, - "version": "1.0.29", + "version": "1.0.30", "type": "module", "homepage": "https://skate-spots.com", "scripts": { diff --git a/release-notes.txt b/release-notes.txt index acfb2e6..8cc8483 100644 --- a/release-notes.txt +++ b/release-notes.txt @@ -1,4 +1,6 @@ -v1.0.29 — Amber login fixed (properly this time) +v1.0.30 — Amber login fixed definitively -- Root cause found: callbackUrl was being double-encoded inside the URL builder, arriving garbled at Amber and causing "malformed request" -- Reverted to clean nostrsigner: format per NIP-55 spec with only callbackUrl encoded once +- Switched to correct Android intent: URL format per Amber README +- Removed # from callbackUrl (# breaks the intent: format which uses # as delimiter) +- Added pre-redirect in main.tsx: when Amber opens /amber-callback, app redirects to /#/amber-callback so HashRouter can match the route +- All params passed raw (no encoding) inside intent: string as required diff --git a/src/lib/amber.ts b/src/lib/amber.ts index dd06153..ae4184f 100644 --- a/src/lib/amber.ts +++ b/src/lib/amber.ts @@ -286,50 +286,48 @@ function generateOperationId(): string { function buildCallbackUrl(): string { const isCapacitor = !!(window as unknown as { Capacitor?: { isNativePlatform?: () => boolean } }).Capacitor?.isNativePlatform?.(); if (isCapacitor) { - // Use the app's custom scheme so Amber fires the intent back into the APK. - // DeepLinkHandler in AppRouter.tsx catches appUrlOpen and navigates to the - // correct route inside the HashRouter. return 'org.skatespots.app://amber-callback?event='; } - // HashRouter: routes live in the # fragment, so the callback URL must be: - // https://skate-spots.org/#/amber-callback?event= - // This is what Amber will open in the browser tab after user approves. - return `${window.location.origin}/#/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 nostrsigner: URL. - * - * Per NIP-55 spec and Amber README, web clients use window.location.href - * with a nostrsigner: URI. The params go in the query string unencoded - * (except the callbackUrl which must be encodeURIComponent'd so Amber - * can parse it as a single value). - * - * Correct format per NIP-55 spec: - * nostrsigner:?type=get_public_key&compressionType=none&returnType=signature&callbackUrl=https://.../?event= + * Navigate to an Amber intent URL. */ function openAmberUrl(url: string): void { window.location.href = url; } /** - * Build a nostrsigner: URL for Amber web usage. + * Build an Android intent URL for Amber — the ONLY format that works from a browser. * - * Per NIP-55 spec the format is: - * nostrsigner:?type=&compressionType=none&returnType=signature&callbackUrl= + * Per Amber README (https://github.com/greenart7c3/Amber) web apps section, + * the exact format is: * - * The callbackUrl is the ONLY value that needs encodeURIComponent. - * All other params are plain query string values. + * 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 query = Object.entries(params) - .map(([k, v]) => { - // callbackUrl must be encoded so Amber treats it as a single value - if (k === 'callbackUrl') return `${k}=${encodeURIComponent(v)}`; - return `${k}=${v}`; - }) - .join('&'); - return `nostrsigner:${payload}?${query}`; + const extras = Object.entries(params) + .map(([k, v]) => `S.${k}=${v}`) + .join(';'); + return `intent:${payload}#Intent;scheme=nostrsigner;${extras};end`; } /** @@ -339,9 +337,9 @@ function buildAmberUrl(payload: string, params: Record): string */ export function openAmberForLoginNoCallback(): void { const amberUrl = buildAmberUrl('', { - type: 'get_public_key', compressionType: 'none', returnType: 'signature', + type: 'get_public_key', }); openAmberUrl(amberUrl); } @@ -368,11 +366,11 @@ export function openAmberForLogin(): void { const currentPath = window.location.hash.replace(/^#/, '') || '/'; localStorage.setItem('amber_return_path', currentPath); - // NIP-55 nostrsigner: URL — Amber opens, user approves, Amber calls callbackUrl + // Android intent URL for Amber — callbackUrl is RAW (not encoded) per Amber README const amberUrl = buildAmberUrl('', { - type: 'get_public_key', compressionType: 'none', returnType: 'signature', + type: 'get_public_key', callbackUrl: callbackUrl, }); diff --git a/src/main.tsx b/src/main.tsx index fbf0e86..030638b 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -28,21 +28,22 @@ import '@fontsource/barlow-condensed/700.css'; // Leaflet CSS is loaded via CDN in index.html /** - * AMBER CALLBACK PRE-CHECK + * AMBER CALLBACK PRE-CHECK + REDIRECT * - * PWAs can sometimes lose URL parameters during React hydration. - * This runs BEFORE React renders to capture Amber callback data. - * We store it in sessionStorage so the AmberCallbackHandler can retrieve it. + * Amber opens the callbackUrl in a browser tab. Because we use HashRouter, + * the callbackUrl has no # fragment — it's a plain path: + * https://skate-spots.org/amber-callback?event= * - * Handles both: - * - Normal query string: ?event=XXX (PWA / browser) - * - HashRouter fragment: #/amber-callback?event=XXX (Capacitor APK) + * This IIFE runs before React mounts and: + * 1. Captures ?event= from the URL into sessionStorage + * 2. If we're on /amber-callback (non-hash path), redirects to /#/amber-callback + * so HashRouter can match the route and render AmberCallbackPage */ (function captureAmberCallback() { - // Try normal query string first + // Try normal query string first (Amber callback: /amber-callback?event=XXX) let eventParam = new URLSearchParams(window.location.search).get('event'); - // Try hash fragment (HashRouter + Capacitor deep link) + // Try hash fragment (Capacitor deep link: #/amber-callback?event=XXX) if (!eventParam) { const hash = window.location.hash; const qIdx = hash.indexOf('?'); @@ -52,7 +53,7 @@ import '@fontsource/barlow-condensed/700.css'; } if (eventParam) { - console.log('[Amber Pre-Check] Detected Amber callback in URL'); + console.log('[Amber Pre-Check] Detected Amber callback, event:', eventParam.substring(0, 16) + '...'); try { sessionStorage.setItem('amber_callback_event', eventParam); sessionStorage.setItem('amber_callback_url', window.location.href); @@ -60,6 +61,17 @@ import '@fontsource/barlow-condensed/700.css'; } catch (e) { console.error('[Amber Pre-Check] Failed to store callback:', e); } + + // If we landed on /amber-callback (non-hash path), redirect into HashRouter + // so React Router can match the route and render AmberCallbackPage. + // The event is already saved in sessionStorage above so it survives the redirect. + const isHashRoute = window.location.hash.startsWith('#/'); + if (!isHashRoute && window.location.pathname.includes('amber-callback')) { + console.log('[Amber Pre-Check] Redirecting to HashRouter route: /#/amber-callback'); + window.location.replace(window.location.origin + '/#/amber-callback'); + // Stop — the page will reload at the correct hash route + return; + } } })();